Skip to main content

lumen_runtime/
run_headless.rs

1//! True headless run mode: the full app pipeline - layout, real
2//! rendering through the app's render backend and the shared Node-IR
3//! walker, the MCP server, input simulation, hot reload, and screenshots -
4//! with zero windows. No window backend runs, so the desktop / compositor
5//! is never touched. This is the automation / CI mode behind
6//! `lumenc run <app> --headless`.
7//!
8//! ## How it differs from [`crate::run::run_app_headless`]
9//!
10//! The bare `run_app_headless` (kept as the FFI / SDK contract) only
11//! ticks the main-world schedule - no renderer, no pixels. This mode
12//! additionally installs the offscreen renderer of the backend
13//! `[render] backend` selects (under `auto`, the first that starts, GPU
14//! before CPU), driven by the same retained-scene walker the windowed
15//! backend runs - so extracted geometry, dpr scaling, and text shaping
16//! behave identically to the windowed path.
17//!
18//! ## Frame pacing
19//!
20//! Ticks run on demand, mirroring the windowed `RedrawScheduler`
21//! semantics without the pause-on-unfocused gate (there is no focus):
22//!
23//! * a wake through [`lumen_core::app::EventLoopWaker`] runs a tick
24//!   immediately: the MCP server thread (simulate push or screenshot
25//!   request), a finished HTTP request, or a module event on the core bus;
26//! * while work is pending (animations mid-flight, undrained external
27//!   property writes, dirty frame), ticks are paced at ~60 Hz - the
28//!   stand-in for vsync;
29//! * a deadline a system asked to be woken at (a script timer, see
30//!   [`lumen_core::tick::WakeDeadline`]) ends the park when it comes due;
31//! * otherwise the loop parks. With hot reload active it re-ticks every
32//!   ~250 ms so the source watcher polls; without it, the loop sleeps
33//!   until the next wake (SIGINT/SIGTERM are still observed within one
34//!   250 ms slice).
35//!
36//! ## Exit
37//!
38//! SIGINT / SIGTERM (and the end of a bounded `--ticks N` run) take the
39//! graceful-close path: a `CloseRequest { vetoed: false }` is written to
40//! the message bus and one final tick runs so close-observing systems
41//! fire, then the fn returns `Ok(())` (process exit code 0). Unlike the
42//! windowed close, a veto does not keep the app alive - a signalled CI
43//! run must terminate.
44
45use std::sync::atomic::{AtomicBool, Ordering};
46use std::sync::{Arc, Condvar, Mutex};
47use std::time::{Duration, Instant};
48
49use bevy_ecs::message::Messages;
50use bevy_ecs::prelude::Resource;
51use lumen_core::input::CloseRequest;
52use lumen_core::prelude::*;
53use lumen_core::render_backend::{RenderBackend, install_offscreen};
54use lumen_core::render_world::{FrameDirty, SurfaceCapture};
55use lumen_core::tick::{wake_deadline, work_pending};
56use lumen_text::{ShapeOptions, ShaperService, TextShaper};
57use lumen_text_cosmic::CosmicShaper;
58
59use crate::run::{RunError, RunOptions, build_headless_app};
60
61/// Wall-clock pacing for back-to-back work ticks (animations, pending
62/// writes). Stands in for vsync; 16.67 ms = 60 Hz. Deadlines are
63/// anchored (`deadline += interval`, park until deadline) rather than
64/// slept after the tick, so the frame period is exactly this - not
65/// `tick work + sleep`, which drifted every frame's worth of work.
66const WORK_FRAME_INTERVAL: Duration = Duration::from_micros(16_667);
67
68/// Idle park slice. Signals and the hot-reload watcher are observed at
69/// this cadence; an MCP wake interrupts it immediately.
70const IDLE_PARK_SLICE: Duration = Duration::from_millis(250);
71
72/// Opt-in boot-phase timing. Set `LUMEN_BOOT_TRACE=1` to print a
73/// phase-by-phase startup breakdown (build/parse/font-scan, renderer
74/// bring-up, shaper warmup, first frame) to stderr - the reproducible
75/// backing for the startup regression story. Off by default: the checks
76/// are a single `env::var_os` read plus a few `Instant::now()` calls on
77/// the cold path, so a normal run pays nothing measurable.
78struct BootTrace {
79    on: bool,
80    start: Instant,
81}
82
83impl BootTrace {
84    fn new() -> Self {
85        Self {
86            on: std::env::var_os("LUMEN_BOOT_TRACE").is_some(),
87            start: Instant::now(),
88        }
89    }
90
91    /// Print one phase line (elapsed within that phase).
92    fn mark(&self, phase: &str, dur: Duration) {
93        if self.on {
94            eprintln!(
95                "boot-trace: {phase:<34} {:>8.2} ms",
96                dur.as_secs_f64() * 1000.0
97            );
98        }
99    }
100
101    /// Under trace only: time a throwaway [`CosmicShaper::new`] so the
102    /// system-font-directory scan cost is attributable in isolation
103    /// (the real scan is buried inside the text install). The
104    /// shaper is dropped immediately; it exists purely to price the
105    /// `FontSystem::new` disk walk that every cold start pays once.
106    fn standalone_fontscan(&self) {
107        if self.on {
108            let t = Instant::now();
109            let s = CosmicShaper::new();
110            let dur = t.elapsed();
111            std::hint::black_box(&s);
112            self.mark("  |- FontSystem::new (standalone)", dur);
113        }
114    }
115
116    /// Total-to-first-frame + resident-set + thread-count summary.
117    fn finish(&self) {
118        if !self.on {
119            return;
120        }
121        self.mark("TOTAL exec->first-frame", self.start.elapsed());
122        #[cfg(target_os = "linux")]
123        if let Ok(status) = std::fs::read_to_string("/proc/self/status") {
124            let field = |k: &str| {
125                status
126                    .lines()
127                    .find(|l| l.starts_with(k))
128                    .map(|l| l.trim())
129                    .unwrap_or("")
130                    .to_string()
131            };
132            eprintln!("boot-trace: {}", field("VmHWM:"));
133            eprintln!("boot-trace: {}", field("Threads:"));
134        }
135    }
136}
137
138/// Options specific to the rendered headless mode. Sizing comes from
139/// [`RunOptions::size`] / `lumen.toml [window] size` exactly like the
140/// windowed path, so it is not duplicated here.
141#[derive(Debug, Clone, Copy)]
142pub struct HeadlessOptions {
143    /// Device pixel ratio for the offscreen target. `Viewport.size` stays
144    /// logical (like the windowed path); the render texture - and thus
145    /// every screenshot - is `logical x dpr` physical pixels.
146    pub dpr: f32,
147    /// `Some(n)`: run exactly `n` ticks back-to-back, then take the
148    /// graceful-close path and return. `None`: run until SIGINT/SIGTERM.
149    pub ticks: Option<u64>,
150}
151
152impl Default for HeadlessOptions {
153    fn default() -> Self {
154        Self {
155            dpr: 1.0,
156            ticks: None,
157        }
158    }
159}
160
161/// Condvar-backed stand-in for the winit event loop's parked wait.
162/// [`Self::wake`] is handed out as the [`lumen_core::app::EventLoopWaker`]
163/// so cross-thread producers (MCP simulate queue, screenshot requests)
164/// interrupt the park exactly like `EventLoopProxy::send_event` would.
165#[derive(Default)]
166struct Parker {
167    notified: Mutex<bool>,
168    cv: Condvar,
169}
170
171impl Parker {
172    fn wake(&self) {
173        let mut g = self
174            .notified
175            .lock()
176            .unwrap_or_else(|poisoned| poisoned.into_inner());
177        *g = true;
178        self.cv.notify_one();
179    }
180
181    /// Park for at most `timeout`. Returns `true` when woken by
182    /// [`Self::wake`] (including a wake that arrived before parking -
183    /// no lost-wakeup window), `false` on timeout.
184    fn park_timeout(&self, timeout: Duration) -> bool {
185        let mut g = self
186            .notified
187            .lock()
188            .unwrap_or_else(|poisoned| poisoned.into_inner());
189        if !*g {
190            let (g2, _res) = self
191                .cv
192                .wait_timeout(g, timeout)
193                .unwrap_or_else(|poisoned| poisoned.into_inner());
194            g = g2;
195        }
196        let was = *g;
197        *g = false;
198        was
199    }
200}
201
202/// Run the full pipeline headless. See the module docs for semantics.
203pub fn run_app_headless_rendered(
204    mut opts: RunOptions,
205    headless: HeadlessOptions,
206) -> Result<(), RunError> {
207    // Rendered headless is an automation / CI run with no interactive
208    // session: gate off the MCP server (unless `[mcp] simulate` is on) and
209    // the hot-reload watcher via the `bounded` flag. See `build_app`.
210    opts.bounded = true;
211    let boot = BootTrace::new();
212    let dpr = headless.dpr.max(0.01);
213    // Renderer bring-up (a GPU device and its pipelines, or a rasterizer)
214    // needs nothing from the app world, so `build_app` starts it on a
215    // spawned thread as soon as the render backends have registered, and it
216    // runs overlapped with the rest of the build (markup/CSS parse, scripts,
217    // ECS spawn) and the shaper warmup below. Sized from the CLI size as a
218    // guess; the render system resizes the target to the viewport, and
219    // every expensive init step is size-independent.
220    opts.offscreen_prestart = Some((
221        (opts.size.0 as f32 * dpr).round().max(1.0) as u32,
222        (opts.size.1 as f32 * dpr).round().max(1.0) as u32,
223    ));
224
225    let t_build = Instant::now();
226    let (mut app, mut window) = build_headless_app(opts)?;
227    boot.mark("build_app (parse+ecs+fontscan)", t_build.elapsed());
228    boot.standalone_fontscan();
229    let renderer_init = app
230        .world
231        .remove_resource::<OffscreenPrestart>()
232        .ok_or_else(|| RunError::Headless("the app build started no renderer".into()))?;
233
234    // While the renderer thread finishes: pre-warm the shapers' cold path
235    // (sans-serif face load + fallback-chain init inside cosmic-text,
236    // ~10-15 ms on first shape) so the first real layout/render tick
237    // doesn't pay it. The strings are throwaway; only the font-system
238    // warmup matters, so a short ASCII pangram at the default UI sizes
239    // and both common weights is enough.
240    let t_warm = Instant::now();
241    {
242        const WARMUP: &str = "The quick brown fox jumps over 0123456789.";
243        let warm = |shaper: &mut dyn TextShaper| {
244            for (size, weight) in [(14.0_f32, 400_u16), (16.0, 400), (14.0, 700)] {
245                let _ = shaper.shape(
246                    WARMUP,
247                    size,
248                    ShapeOptions {
249                        weight,
250                        ..Default::default()
251                    },
252                );
253            }
254        };
255        if let Some(mut layout_shaper) = app.world.get_non_send_mut::<ShaperService>() {
256            warm(&mut **layout_shaper);
257        }
258        if let Some(render_shaper) = window.text_shaper.as_deref_mut() {
259            warm(render_shaper);
260        }
261    }
262    boot.mark("shaper warmup (overlap renderer)", t_warm.elapsed());
263    // Hot-reload wakes: with the notify watcher active (the default), fs
264    // events wake the parked loop directly and idle stays at zero ticks.
265    // Only the poll fallback (`LUMEN_HOT_RELOAD_POLL` / watcher init
266    // failure) still needs the periodic idle slices to re-tick.
267    // Hot reload (and its poll driver) only exists in `runtime-parse` builds;
268    // a parser-free runtime has no source to re-read, so it never needs the
269    // periodic re-tick slices.
270    #[cfg(feature = "runtime-parse")]
271    let hot_reload_poll = matches!(
272        app.world.get_resource::<crate::run::HotReloadDriver>(),
273        Some(crate::run::HotReloadDriver::Poll)
274    );
275    #[cfg(not(feature = "runtime-parse"))]
276    let hot_reload_poll = false;
277
278    // Viewport: logical size from the resolved window options (CLI --size
279    // beats `lumen.toml [window] size` beats the built-in default, exactly
280    // like windowed), scale factor from --dpr. Mirrors the viewport seed a
281    // window backend writes before its window opens, plus its dpr reconcile.
282    let logical = glam::Vec2::new(window.options.size.0 as f32, window.options.size.1 as f32);
283    for world in [&mut app.world, &mut app.render_world] {
284        let mut vp = world.resource_mut::<Viewport>();
285        vp.size = logical;
286        vp.scale_factor = dpr;
287        vp.clear = window.options.clear;
288    }
289
290    // Join the renderer thread. A GPU adapter is requested without a
291    // surface, so a machine with no display still gets one where a driver
292    // exists; under `auto` a backend that cannot start hands over to the
293    // next. Init failure of every candidate surfaces as an error.
294    let t_join = Instant::now();
295    let (renderer_res, init_wall) = renderer_init.join()?;
296    boot.mark("renderer_join_wait (main blocked)", t_join.elapsed());
297    boot.mark("  |- renderer_init_wall (bg thread)", init_wall);
298    let renderer = renderer_res.map_err(RunError::Headless)?;
299    // The text shaper built for the windowed path is the render world's,
300    // so glyph output matches the window byte-for-byte.
301    if let Some(shaper) = window.text_shaper.take() {
302        app.render_world
303            .insert_non_send(ShaperService::from(shaper));
304    }
305    install_offscreen(&mut app, renderer);
306
307    // Wake plumbing: the same EventLoopWaker contract a window backend
308    // provides, backed by a condvar instead of an event-loop proxy.
309    // `wire_simulate_waker` (MCP plugin) picks the resource up on the
310    // first tick; the SurfaceCapture waker is shared via its OnceLock.
311    let parker = Arc::new(Parker::default());
312    let waker = {
313        let p = Arc::clone(&parker);
314        lumen_core::app::EventLoopWaker(Arc::new(move || p.wake()))
315    };
316    if let Some(capture) = app.world.get_resource::<SurfaceCapture>() {
317        capture.set_waker(waker.clone());
318    }
319    // A module event pushed onto the core bus while the loop is idle-parked
320    // wakes it, mirroring the windowed backend.
321    lumen_core::plugin_events::set_plugin_event_waker(waker.clone());
322    app.world.insert_resource(waker);
323
324    // SIGINT / SIGTERM (Unix) or Ctrl+C / Ctrl+Break / console-close
325    // (Windows) -> flag; the loop notices within one park slice.
326    //
327    // The Windows console handler is process-wide and ctrlc rejects a second
328    // registration, so it is installed once and its flag is shared by every
329    // headless run in the process. Registering per run would fail the second
330    // app a process starts.
331    #[cfg(windows)]
332    let exit_flag = {
333        static CTRL_C_FLAG: std::sync::OnceLock<Arc<AtomicBool>> = std::sync::OnceLock::new();
334        let flag = CTRL_C_FLAG.get_or_init(|| {
335            let flag = Arc::new(AtomicBool::new(false));
336            let handler_flag = Arc::clone(&flag);
337            if let Err(e) = ctrlc::set_handler(move || handler_flag.store(true, Ordering::SeqCst)) {
338                eprintln!("lumen: no console-ctrl handler ({e}); Ctrl+C will not exit cleanly");
339            }
340            flag
341        });
342        // Start from a clear flag: an earlier run in this process may have set it.
343        flag.store(false, Ordering::SeqCst);
344        Arc::clone(flag)
345    };
346    #[cfg(not(windows))]
347    let exit_flag = Arc::new(AtomicBool::new(false));
348    #[cfg(unix)]
349    for sig in [signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM] {
350        signal_hook::flag::register(sig, Arc::clone(&exit_flag))
351            .map_err(|e| RunError::Headless(format!("signal handler: {e}")))?;
352    }
353
354    eprintln!(
355        "lumenc: headless mode - no window; {}x{} logical @ dpr {dpr}{}",
356        window.options.size.0,
357        window.options.size.1,
358        match headless.ticks {
359            Some(n) => format!(", bounded to {n} tick(s)"),
360            None => String::new(),
361        }
362    );
363
364    let mut ticked: u64 = 0;
365    // Deadline anchor for work-paced frames. `Some(d)` = the deadline the
366    // frame we just ran was released at; the next frame is due at
367    // `d + WORK_FRAME_INTERVAL` regardless of how long the tick took -
368    // deadline-anchored pacing with no per-frame work drift and no
369    // accumulation error. Cleared on idle so the next burst re-anchors
370    // to "now" instead of firing a catch-up run.
371    let mut next_frame_deadline: Option<Instant> = None;
372    while !exit_flag.load(Ordering::Relaxed) {
373        // A pending off-thread screenshot runs a tick even when nothing
374        // changed, so the renderer's system answers it this iteration
375        // (mirrors the windowed `present_frame` capture bypass of the
376        // idle-frame retain).
377        let capture_pending = app
378            .render_world
379            .get_resource::<SurfaceCapture>()
380            .is_some_and(|c| c.is_requested());
381        if capture_pending && let Some(mut fd) = app.world.get_resource_mut::<FrameDirty>() {
382            fd.dirty = true;
383        }
384
385        let t_tick = (boot.on && ticked == 0).then(Instant::now);
386        app.tick();
387        ticked += 1;
388        if let Some(t) = t_tick {
389            boot.mark("first_tick (layout+extract+render)", t.elapsed());
390            boot.finish();
391        }
392
393        // The frame (if any) is encoded; clear dirty like the windowed
394        // present does. Systems that dirtied state after the encode
395        // re-raise it and the work check below schedules a follow-up.
396        if let Some(mut fd) = app.world.get_resource_mut::<FrameDirty>() {
397            fd.dirty = false;
398        }
399
400        if let Some(n) = headless.ticks
401            && ticked >= n
402        {
403            break;
404        }
405
406        // The same check the windowed `present_frame` re-arms the redraw on.
407        let pending = work_pending(&app.world);
408
409        // Bounded runs tick back-to-back; `--ticks N` bounds wall time.
410        if headless.ticks.is_some() {
411            continue;
412        }
413
414        if pending {
415            // Pace follow-up frames at 60 Hz (vsync stand-in) against an
416            // advancing deadline. An MCP wake cuts the park short (the
417            // anchor is kept, so an early wake doesn't shift the phase of
418            // subsequent frames); falling more than one frame behind
419            // re-anchors to "now" instead of bursting catch-up ticks.
420            let now = Instant::now();
421            let mut deadline = match next_frame_deadline {
422                Some(d) => d + WORK_FRAME_INTERVAL,
423                None => now + WORK_FRAME_INTERVAL,
424            };
425            if deadline < now {
426                deadline = now;
427            }
428            // Wake-cut-short ticks run ahead of their deadline; if a
429            // burst of wakes outpaces 60 Hz the chained anchor would run
430            // arbitrarily far into the future and stall the next paced
431            // frame. Never schedule more than one interval out.
432            if deadline > now + WORK_FRAME_INTERVAL {
433                deadline = now + WORK_FRAME_INTERVAL;
434            }
435            next_frame_deadline = Some(deadline);
436            loop {
437                let now = Instant::now();
438                if now >= deadline {
439                    break;
440                }
441                if parker.park_timeout(deadline - now) {
442                    break;
443                }
444            }
445        } else {
446            next_frame_deadline = None;
447            // Idle-park until an MCP wake or the earliest deadline a
448            // driver asked to be woken at (a script timer). Timeout
449            // slices keep signals - and, when hot reload is on, the
450            // source watcher - serviced.
451            let wake_at = wake_deadline(&app.world);
452            loop {
453                if exit_flag.load(Ordering::Relaxed) {
454                    break;
455                }
456                let slice = match wake_at {
457                    Some(at) => match at.checked_duration_since(Instant::now()) {
458                        Some(left) if !left.is_zero() => left.min(IDLE_PARK_SLICE),
459                        _ => break,
460                    },
461                    None => IDLE_PARK_SLICE,
462                };
463                let woken = parker.park_timeout(slice);
464                // Tick on: an explicit wake (MCP, notify hot-reload
465                // watcher), a poll slice when the mtime fallback is
466                // active, or the deadline coming due. Otherwise stay
467                // parked - zero ticks at idle, like the windowed
468                // scheduler.
469                if woken || hot_reload_poll || wake_at.is_some_and(|at| Instant::now() >= at) {
470                    break;
471                }
472            }
473        }
474    }
475
476    // Graceful close: same message the windowed backend emits on
477    // `CloseRequested`, plus one tick so close-observing systems fire.
478    if let Some(mut msgs) = app.world.get_resource_mut::<Messages<CloseRequest>>() {
479        msgs.write(CloseRequest { vetoed: false });
480    }
481    app.tick();
482    Ok(())
483}
484
485/// An offscreen renderer coming up on its own thread while the app builds.
486#[derive(Resource)]
487pub(crate) struct OffscreenPrestart(
488    std::sync::Mutex<Option<std::thread::JoinHandle<(OffscreenResult, Duration)>>>,
489);
490
491/// What starting an offscreen renderer produced.
492type OffscreenResult = Result<Box<dyn Renderer>, String>;
493
494impl OffscreenPrestart {
495    /// Start the first of `candidates` that comes up, at `width` x `height`,
496    /// on a new thread.
497    pub(crate) fn spawn(
498        candidates: Vec<RenderBackend>,
499        width: u32,
500        height: u32,
501    ) -> Result<Self, RunError> {
502        let handle = std::thread::Builder::new()
503            .name("lumen-renderer-init".into())
504            .spawn(move || {
505                let t = Instant::now();
506                let r = first_offscreen(&candidates, width, height);
507                (r, t.elapsed())
508            })
509            .map_err(|e| RunError::Headless(format!("spawn renderer init thread: {e}")))?;
510        Ok(Self(std::sync::Mutex::new(Some(handle))))
511    }
512
513    /// Wait for the renderer, and how long its bring-up took.
514    fn join(self) -> Result<(OffscreenResult, Duration), RunError> {
515        let handle = self
516            .0
517            .into_inner()
518            .unwrap_or_else(|poisoned| poisoned.into_inner())
519            .ok_or_else(|| RunError::Headless("the renderer was already taken".into()))?;
520        handle
521            .join()
522            .map_err(|_| RunError::Headless("renderer init thread panicked".into()))
523    }
524}
525
526/// The offscreen renderer of the first of `candidates` that starts, at
527/// `width` x `height` physical pixels. A backend that cannot start is
528/// reported and the next one tried; when none starts, the error names each
529/// one's reason.
530fn first_offscreen(
531    candidates: &[RenderBackend],
532    width: u32,
533    height: u32,
534) -> Result<Box<dyn Renderer>, String> {
535    let mut failures = Vec::new();
536    for backend in candidates {
537        let mut renderer = (backend.renderer)();
538        match renderer.attach(FrameTarget::Offscreen { width, height }) {
539            Ok(()) => {
540                if !failures.is_empty() {
541                    eprintln!(
542                        "lumenc: {}; rendering with the '{}' backend",
543                        failures.join("; "),
544                        backend.name
545                    );
546                }
547                return Ok(renderer);
548            }
549            Err(why) => failures.push(format!(
550                "the '{}' render backend did not start: {why}",
551                backend.name
552            )),
553        }
554    }
555    Err(if failures.is_empty() {
556        "no render backend to start".to_string()
557    } else {
558        failures.join("; ")
559    })
560}