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}