Skip to main content

frame_host/
application.rs

1//! The embedding seam's orchestration (design §4.1/§4.2): boot the full
2//! stack around an application's [`AppSpec`], serve until a stop, tear the
3//! whole stack down in order.
4//!
5//! Boot order: component runtime → component register/start → the
6//! application's readiness proof → embedded bus boot → document binding
7//! (when configured) → application-event announcer connect → the
8//! application's fact announcements (retained by [`crate::truth::AppTruth`]
9//! the instant they are accepted, independent of the bus). Serving BINDS the
10//! page server FIRST, then starts the announcer pump draining its retained
11//! backlog (2026-07-21 boot-visibility fix — see [`crate::truth`] for the
12//! defect this order used to have and the snapshot endpoint that closes the
13//! remaining race even so); ordered teardown runs announcer intake close,
14//! document binding stop, component ordered stop (whose transitions the
15//! still-connected announcer publishes), announcer join, app-truth recorder
16//! join, and the bus's graceful drain — every failure typed, logged, and
17//! never hidden behind an earlier one.
18//!
19//! frame-host's own binary is the first consumer of this seam
20//! ([`crate::app`]); the generated scaffold is the second.
21
22use std::collections::HashSet;
23use std::num::NonZeroUsize;
24use std::sync::Arc;
25use std::thread::JoinHandle;
26
27use frame_core::component::ComponentId;
28use frame_core::event::LifecycleSubscription;
29
30use crate::announcer::Announcer;
31use crate::config::FrameConfig;
32use crate::doc_binding::DocBinding;
33use crate::embedded::EmbeddedLiminal;
34use crate::error::HostError;
35use crate::page::PageServer;
36use crate::runtime::{EventLoggerHandle, HostRuntime};
37use crate::serve::{ServeStop, bind};
38use crate::spec::{AppSpec, ComponentInstall, ReadinessProbe};
39use crate::truth::AppTruth;
40
41/// Bounded buffer between the registry's lifecycle stream and the
42/// announcer. Overflow drops oldest-first and is reported loudly by the
43/// announcer pump — the same posture as the console logger's buffer.
44const ANNOUNCER_EVENT_BUFFER: usize = 256;
45
46/// A booted application stack: the component runtime with the application's
47/// components running, the embedded bus, the optional document binding, and
48/// the application-event announcer (absent — loudly — only when the config
49/// declares no `[frame].channel`).
50pub struct Application {
51    runtime: HostRuntime,
52    logger: EventLoggerHandle,
53    liminal: EmbeddedLiminal,
54    doc_binding: Option<DocBinding>,
55    announcer: Option<Announcer>,
56    bus_endpoint: String,
57    components: HashSet<ComponentId>,
58    /// Process-lifetime application-truth recorder (2026-07-21
59    /// boot-visibility fix): every lifecycle transition and announced fact
60    /// this process has produced, served at
61    /// `crate::server::APP_STATUS_ROUTE` so a client joining at any moment
62    /// observes the full boot history over plain HTTP — no bus race, no
63    /// out-of-band subscribe-before-serve shortcut required. See
64    /// [`crate::truth`] for the full defect/fix writeup.
65    truth: Arc<AppTruth>,
66    /// Join handle for the dedicated recorder thread ([`AppTruth::spawn`]).
67    truth_recorder: JoinHandle<()>,
68}
69
70impl Application {
71    /// Boots the full stack around the application's spec, in order. A boot
72    /// failure tears down whatever already booted so no component is left
73    /// running; the first typed failure is what gets reported.
74    ///
75    /// # Errors
76    ///
77    /// Returns the first typed failure from spec validation, runtime
78    /// composition, component install, the application's readiness proof,
79    /// the embedded bus boot, the document binding, the announcer connect
80    /// (a typed boot failure — never a silently event-less page), or the
81    /// application's fact announcements.
82    pub fn boot(config: &FrameConfig, spec: AppSpec) -> Result<Self, HostError> {
83        if spec.components.is_empty() {
84            return Err(HostError::ConfigContract {
85                detail: "the application spec installs no components: a host with nothing to \
86                         host is a composition error, not an empty stack"
87                    .to_owned(),
88            });
89        }
90        let components: HashSet<ComponentId> = spec
91            .components
92            .iter()
93            .map(|install| install.meta.id)
94            .collect();
95
96        let mut runtime = HostRuntime::new(spec.policy)?;
97
98        // The announcer's lifecycle subscription is taken BEFORE install so
99        // the boot transitions are retained for publication once the pump
100        // goes live; the console logger subscribes before install for the
101        // same reason.
102        let announcer_subscription: Option<LifecycleSubscription> = match &config.frame.channel {
103            Some(_) => {
104                let capacity = NonZeroUsize::new(ANNOUNCER_EVENT_BUFFER)
105                    .ok_or(HostError::SynchronizationPoisoned)?;
106                Some(runtime.registry().subscribe(capacity)?)
107            }
108            None => None,
109        };
110        let logger = runtime.spawn_event_logger(components.clone())?;
111
112        // The application-truth recorder (2026-07-21 boot-visibility fix):
113        // a THIRD dedicated lifecycle subscription, taken here for the same
114        // reason as the two above — so the retained boot transitions are
115        // never missed. Recording is independent of whether
116        // `[frame].channel` is declared: this is host-local truth served
117        // over plain HTTP (`/frame/app/status.json`), not a bus publish.
118        // See `crate::truth` for the full defect/fix writeup.
119        let (truth, truth_recorder) = AppTruth::spawn(runtime.registry(), components.clone())?;
120
121        // Installs every declared component, then runs the application's
122        // post-start readiness proof (design §4.2), before the bus boots and
123        // before anything serves; either failure best-effort abandons
124        // whatever installed so far so a failed boot never leaks a process
125        // tree.
126        install_and_verify_ready(&mut runtime, spec.components, spec.readiness)?;
127
128        // Boot the embedded bus component in-process (its own threads; no
129        // child process). A bind/boot failure exits with the failing
130        // component named — never a half-up stack. The components already
131        // running are torn down first so they are not abandoned.
132        let liminal = match EmbeddedLiminal::boot(&config.bus) {
133            Ok(liminal) => liminal,
134            Err(error) => {
135                if let Err(teardown) = runtime.shutdown(logger) {
136                    tracing::error!(
137                        %teardown,
138                        "frame-core teardown after bus boot failure also failed"
139                    );
140                }
141                join_truth_recorder(truth_recorder, "bus boot failure");
142                return Err(error);
143            }
144        };
145        let bus_endpoint = liminal.websocket_endpoint();
146        tracing::info!(
147            endpoint = %bus_endpoint,
148            "embedded bus component booted; /frame/config.json advertises its real bound WebSocket address as busEndpoint"
149        );
150
151        // The document-service binding when [document] is configured.
152        let doc_binding = match DocBinding::boot(&liminal, config) {
153            Ok(binding) => binding,
154            Err(error) => {
155                teardown_after_boot_failure(
156                    None,
157                    liminal,
158                    runtime,
159                    logger,
160                    truth_recorder,
161                    "document-service",
162                );
163                return Err(error);
164            }
165        };
166
167        // The application-event announcer (design §4.3): a SEPARATE native
168        // participant connection to the embedded server's real bound TCP
169        // address. The page server itself carries zero feed bytes (ruling
170        // D3); the announcer is a node on the bus, not a proxy in front of
171        // it. A failed connect is a typed boot failure.
172        let announcer = match connect_announcer(config, &liminal, announcer_subscription) {
173            Ok(announcer) => announcer,
174            Err(error) => {
175                teardown_after_boot_failure(
176                    doc_binding,
177                    liminal,
178                    runtime,
179                    logger,
180                    truth_recorder,
181                    "announcer connect",
182                );
183                return Err(error);
184            }
185        };
186        // Mirror every fact this announcer accepts into the app-truth
187        // snapshot (2026-07-21 boot-visibility fix) — attached BEFORE the
188        // boot fact announcement below, so it is retained here too.
189        if let Some(announcer) = &announcer {
190            announcer.attach_truth(Arc::clone(&truth));
191        }
192
193        // The application's own fact announcements, through the connected
194        // announcer. Queued facts publish once the pump goes live at serve
195        // time, after the retained boot transitions.
196        if let Err(error) = announce_boot_facts(spec.announce, &runtime, announcer.as_ref()) {
197            teardown_after_boot_failure(
198                doc_binding,
199                liminal,
200                runtime,
201                logger,
202                truth_recorder,
203                "fact announcement",
204            );
205            return Err(error);
206        }
207
208        Ok(Self {
209            runtime,
210            logger,
211            liminal,
212            doc_binding,
213            announcer,
214            bus_endpoint,
215            components,
216            truth,
217            truth_recorder,
218        })
219    }
220
221    /// Serves the page until SIGTERM, SIGINT, `external_stop` resolving, or
222    /// a bus liveness failure — blocking.
223    ///
224    /// SERVE-ORDER FIX (2026-07-21 boot-visibility fix, [`crate::truth`]):
225    /// the HTTP+WS surface binds FIRST; the announcer pump only starts
226    /// draining its retained boot backlog (transitions, then queued facts,
227    /// then live events) once that surface is reachable. Before this fix the
228    /// pump started as this function's first action — before the tokio
229    /// runtime even existed — so the entire backlog drained before
230    /// `/frame/config.json` (the only doctrine-sanctioned way a browser
231    /// discovers the bus address) was fetchable at all; no real browser
232    /// could ever win that race. `/frame/app/status.json` (also served the
233    /// instant this binds) closes the remaining gap: a client that still
234    /// arrives after the live drain observes the full boot history there
235    /// instead of racing the bus.
236    ///
237    /// # Errors
238    ///
239    /// Returns typed failures from binding the page server, starting the
240    /// announcer pump, building the async runtime, registering signal
241    /// handlers, or the accept loop. The caller still owns the stack and
242    /// must drive [`Self::shutdown`].
243    pub fn serve(
244        &self,
245        config: &FrameConfig,
246        page: PageServer,
247        external_stop: impl Future<Output = ()> + Send + 'static,
248    ) -> Result<ServeStop, HostError> {
249        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
250            .enable_all()
251            .build()
252            .map_err(|source| HostError::AsyncRuntime { source })?;
253        // SERVE-ORDER FIX (2026-07-21 boot-visibility fix): the HTTP+WS
254        // surface binds FIRST, inside `serve` below; the announcer pump only
255        // starts draining its retained boot backlog AFTER that surface is
256        // reachable — moved out of the position this function used to start
257        // it in (its former first line, before the tokio runtime even
258        // existed). A real browser fetches `/frame/config.json` and only
259        // then subscribes; starting the pump before the surface bound meant
260        // the entire backlog drained before that fetch was even possible.
261        // See `crate::truth` for the snapshot half of this fix, which closes
262        // the remaining gap even when a client races the pump regardless.
263        tokio_runtime.block_on(async {
264            let bound = bind(
265                config,
266                &self.liminal,
267                self.bus_endpoint.clone(),
268                &self.truth,
269                page,
270            )
271            .await?;
272            if let Some(announcer) = &self.announcer {
273                announcer.start(self.components.clone())?;
274            }
275            bound.serve(external_stop).await
276        })
277    }
278
279    /// Ordered teardown, always running every step and hiding no failure:
280    /// announcer intake closes first, the document binding stops (its pump
281    /// rides the bus), the components stop and remove in order (their
282    /// transitions publish through the still-connected announcer, whose
283    /// pump exits on the final Removed), the announcer joins, then the bus
284    /// drains gracefully.
285    ///
286    /// # Errors
287    ///
288    /// Returns the first typed teardown failure by stage precedence
289    /// (document, components, announcer, bus); every later failure is
290    /// still executed and logged before the first is returned.
291    pub fn shutdown(self) -> Result<(), HostError> {
292        // Design §4.1 teardown order, step 1: the announcer stops taking
293        // new facts. Its pump keeps publishing until the final Removed.
294        if let Some(announcer) = &self.announcer {
295            announcer.close_intake();
296        }
297
298        let doc_shutdown = match self.doc_binding {
299            None => Ok(()),
300            Some(binding) => binding.shutdown(),
301        };
302        if let Err(ref error) = doc_shutdown {
303            tracing::error!(%error, "document-service shutdown failed");
304        }
305
306        let host_shutdown = self.runtime.shutdown(self.logger);
307        if let Err(ref error) = host_shutdown {
308            tracing::error!(%error, "frame-core ordered shutdown failed");
309        }
310
311        // Join the announcer only after a completed component shutdown has
312        // published every Removed transition (the pump's exit condition);
313        // after a FAILED component shutdown the pump may never observe them,
314        // so the announcer is dropped un-joined — loudly — instead of
315        // hanging the teardown on it.
316        let announcer_shutdown = match self.announcer {
317            None => Ok(()),
318            Some(announcer) => {
319                if host_shutdown.is_ok() {
320                    match announcer.stop() {
321                        Ok(None) => Ok(()),
322                        Ok(Some(death)) => {
323                            tracing::warn!(
324                                death,
325                                "the application-event announcer had died at runtime; its \
326                                 recorded death detail is reported here at teardown"
327                            );
328                            Ok(())
329                        }
330                        Err(error) => Err(error),
331                    }
332                } else {
333                    tracing::error!(
334                        "component shutdown failed; abandoning the announcer pump un-joined \
335                         rather than hanging teardown on transitions that will never arrive"
336                    );
337                    drop(announcer);
338                    Ok(())
339                }
340            }
341        };
342        if let Err(ref error) = announcer_shutdown {
343            tracing::error!(%error, "announcer shutdown failed");
344        }
345
346        // The app-truth recorder observes the SAME expected-component-Removed
347        // exit condition as the console logger, published by the ordered
348        // component shutdown above — join it only once that has actually
349        // happened; a FAILED component shutdown means those Removed
350        // transitions may never arrive, so the recorder is abandoned
351        // un-joined — loudly — rather than hanging teardown on it (the exact
352        // posture the announcer's own join takes just above).
353        let truth_shutdown = if host_shutdown.is_ok() {
354            self.truth_recorder
355                .join()
356                .map_err(|_| HostError::TruthRecorderPanicked)
357        } else {
358            tracing::error!(
359                "component shutdown failed; abandoning the app-truth recorder un-joined rather \
360                 than hanging teardown on transitions that will never arrive"
361            );
362            drop(self.truth_recorder);
363            Ok(())
364        };
365        if let Err(ref error) = truth_shutdown {
366            tracing::error!(%error, "app-truth recorder shutdown failed");
367        }
368
369        let liminal_shutdown = self.liminal.shutdown();
370        if let Err(ref error) = liminal_shutdown {
371            tracing::error!(%error, "embedded bus graceful shutdown failed");
372        }
373
374        doc_shutdown?;
375        host_shutdown?;
376        announcer_shutdown?;
377        truth_shutdown?;
378        liminal_shutdown?;
379        Ok(())
380    }
381}
382
383/// Installs every declared component, then runs the application's readiness
384/// proof — extracted from [`Application::boot`] purely to keep that
385/// function's line count within the workspace's clippy budget; the boot-time
386/// cleanup posture is unchanged: either failure best-effort abandons
387/// whatever installed so far (`abandon_after_boot_failure`) before the
388/// typed error propagates, exactly as the two checks did inline.
389///
390/// # Errors
391///
392/// Returns the runtime's typed install failure, or
393/// [`HostError::Application`] carrying the readiness proof's typed refusal.
394fn install_and_verify_ready(
395    runtime: &mut HostRuntime,
396    components: Vec<ComponentInstall>,
397    readiness: ReadinessProbe,
398) -> Result<(), HostError> {
399    if let Err(error) = runtime.install(components) {
400        runtime.abandon_after_boot_failure();
401        return Err(error);
402    }
403    if let Err(source) = readiness(runtime.registry()) {
404        runtime.abandon_after_boot_failure();
405        return Err(HostError::Application {
406            stage: "readiness",
407            source,
408        });
409    }
410    Ok(())
411}
412
413/// Connects the application-event announcer over the embedded server's real
414/// bound TCP wire address when `[frame].channel` is declared; otherwise the
415/// announcer is disabled LOUDLY (documented: the served page then observes
416/// no host events on any channel).
417///
418/// # Errors
419///
420/// Returns [`HostError::AnnouncerConnect`] — a typed boot failure, never a
421/// silently event-less page.
422fn connect_announcer(
423    config: &FrameConfig,
424    liminal: &EmbeddedLiminal,
425    subscription: Option<LifecycleSubscription>,
426) -> Result<Option<Announcer>, HostError> {
427    let (Some(channel), Some(subscription)) = (&config.frame.channel, subscription) else {
428        tracing::warn!(
429            "no [frame].channel is declared: the application-event announcer is disabled and \
430             the served page will observe no host events on any channel; declare \
431             [frame].channel to announce lifecycle transitions, capability denials, and \
432             application facts"
433        );
434        return Ok(None);
435    };
436    let auth_token = config
437        .bus
438        .auth
439        .as_ref()
440        .map(|auth| auth.token.clone().into_bytes());
441    Announcer::connect(
442        &liminal.tcp_addr().to_string(),
443        auth_token.as_deref(),
444        channel.clone(),
445        subscription,
446    )
447    .map(Some)
448}
449
450/// Runs the application's boot-time fact announcements through the
451/// connected announcer, or skips them LOUDLY when no announcer exists.
452///
453/// # Errors
454///
455/// Returns [`HostError::Application`] carrying the application's own typed
456/// refusal.
457fn announce_boot_facts(
458    announce: crate::spec::FactAnnouncement,
459    runtime: &HostRuntime,
460    announcer: Option<&Announcer>,
461) -> Result<(), HostError> {
462    let Some(announcer) = announcer else {
463        tracing::warn!("application facts are not announced: no [frame].channel is declared");
464        return Ok(());
465    };
466    announce(runtime.registry(), announcer).map_err(|source| HostError::Application {
467        stage: "announce",
468        source,
469    })
470}
471
472/// Best-effort ordered teardown after a boot-stage failure, each failure
473/// logged loudly; the original boot failure is what the caller reports.
474fn teardown_after_boot_failure(
475    doc_binding: Option<DocBinding>,
476    liminal: EmbeddedLiminal,
477    runtime: HostRuntime,
478    logger: EventLoggerHandle,
479    truth_recorder: JoinHandle<()>,
480    failed_stage: &str,
481) {
482    if let Some(binding) = doc_binding
483        && let Err(teardown) = binding.shutdown()
484    {
485        tracing::error!(
486            %teardown,
487            failed_stage,
488            "document-service teardown after boot failure also failed"
489        );
490    }
491    if let Err(teardown) = liminal.shutdown() {
492        tracing::error!(
493            %teardown,
494            failed_stage,
495            "embedded bus teardown after boot failure also failed"
496        );
497    }
498    if let Err(teardown) = runtime.shutdown(logger) {
499        tracing::error!(
500            %teardown,
501            failed_stage,
502            "frame-core teardown after boot failure also failed"
503        );
504    }
505    join_truth_recorder(truth_recorder, failed_stage);
506}
507
508/// Best-effort join of the app-truth recorder thread after a boot-stage
509/// failure. The recorder's exit condition (every expected component's
510/// Removed transition) is the SAME one the console logger relies on,
511/// published by the `runtime.shutdown`/`abandon_after_boot_failure` call
512/// just above each of this function's call sites — so by the time this
513/// runs, the join is expected to complete promptly. A panicked recorder is
514/// logged loudly, never hidden behind the original boot failure the caller
515/// reports.
516fn join_truth_recorder(truth_recorder: JoinHandle<()>, failed_stage: &str) {
517    if truth_recorder.join().is_err() {
518        tracing::error!(
519            failed_stage,
520            "app-truth recorder thread panicked during teardown after a boot failure"
521        );
522    }
523}
524
525/// Wait quantum for the dev management adapter's pump. The effective
526/// granularity is the substrate's IO quantum (hardcoded 5 s at the
527/// pinned liminal — upstream ASK-2); this value carries the intent for
528/// the day the quantum becomes settable. Quiet ticks are EMPTY by ruling
529/// (brief §Adapter pump sanction).
530const DEV_PUMP_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
531
532/// [`run_application`], started in DEV mode (F-7b): identical boot and
533/// serve, plus the management door — the conversation is opened on the
534/// embedded bus, the handoff line is printed on stdout, and the adapter
535/// thread serves stage-and-activate requests behind the
536/// [`crate::dev::require_dev_node`] gate. A dev-client disconnect is an
537/// owner-defined stop: the serve future resolves and the ordinary ordered
538/// teardown runs — never scheduler-shutdown-as-cleanup.
539///
540/// # Errors
541///
542/// Returns the first typed failure from boot, the door open, serving, or
543/// ordered shutdown.
544pub fn run_dev_application(
545    mut config: FrameConfig,
546    spec: AppSpec,
547    page: PageServer,
548    dev: crate::dev::DevWiring,
549) -> Result<(), HostError> {
550    use std::sync::Arc;
551    use std::sync::atomic::{AtomicBool, Ordering};
552
553    config.finalize_page_origins(page.local_addr());
554    let app = Application::boot(&config, spec)?;
555
556    let endpoint = app.liminal.tcp_addr().to_string();
557    let attachment = frame_conv::BusAttachment {
558        endpoint: endpoint.clone(),
559        session_capacity: 1,
560        operation_window_millis: 10,
561        inbound_buffer: 16,
562        answer_window: std::time::Duration::from_secs(30),
563    };
564    let (mut conversation, conversation_id) = crate::dev::DevManagementConversation::open(
565        &attachment,
566        crate::dev::DevResumeStore::default(),
567    )
568    .map_err(|error| HostError::DevManagement {
569        detail: error.to_string(),
570    })?;
571    // The one handoff line `frame dev` parses from the piped stdout.
572    println!(
573        "{}",
574        crate::dev::format_management_line(&endpoint, conversation_id)
575    );
576
577    let mut engine = crate::dev::BarrierEngine::new(crate::dev::NodeMode::Dev, dev.boot);
578    let support = app
579        .runtime
580        .first_support_ref()
581        .ok_or_else(|| HostError::DevManagement {
582            detail: "dev wiring requires the application's FFI support module".to_owned(),
583        })?;
584    let mut control = crate::dev::DevNodeControl::new(
585        app.runtime.registry_handle(),
586        app.runtime.support_swapper(),
587        dev.meta,
588        support,
589        dev.mailbox_proof,
590        dev.content_proof,
591    );
592
593    let close = Arc::new(AtomicBool::new(false));
594    let close_for_adapter = Arc::clone(&close);
595    let (exit_tx, exit_rx) = tokio::sync::oneshot::channel::<crate::dev::ServeExit>();
596    let adapter = std::thread::Builder::new()
597        .name("frame-dev-management".to_owned())
598        .spawn(move || {
599            let exit = crate::dev::serve(
600                &mut conversation,
601                &mut engine,
602                &mut control,
603                &close_for_adapter,
604                DEV_PUMP_WAIT,
605            );
606            let _ = exit_tx.send(exit);
607        })
608        .map_err(|source| HostError::DevManagement {
609            detail: format!("adapter thread spawn: {source}"),
610        })?;
611
612    // Serving ends on process signals, bus liveness failure, OR the
613    // adapter's exit (dev-client disconnect / connection fate) — each
614    // path runs the same ordered teardown below.
615    let serve_outcome = app.serve(&config, page, async {
616        match exit_rx.await {
617            Ok(exit) => {
618                tracing::info!(?exit, "dev management adapter exited; ordered stop");
619            }
620            Err(_closed) => {
621                tracing::info!("dev management adapter closed without an exit report");
622            }
623        }
624    });
625    // Ordered close-wake for the adapter on OUR teardown paths (signal,
626    // bus failure): raise the flag, then join — bounded by the pump wait.
627    close.store(true, Ordering::Release);
628    if adapter.join().is_err() {
629        tracing::error!("dev management adapter thread panicked during teardown");
630    }
631    let shutdown_outcome = app.shutdown();
632    let stop = serve_outcome?;
633    if let ServeStop::LiminalExited { detail } = stop {
634        return Err(HostError::LiminalExited { detail });
635    }
636    shutdown_outcome?;
637    tracing::info!("frame dev server exited cleanly");
638    Ok(())
639}
640
641/// Loads nothing itself: boots the full stack around the supplied
642/// application spec from an already-loaded config and an already-resolved,
643/// already-bound page server ([`PageServer`]), serves until a shutdown signal
644/// (or a bus liveness failure), then tears the whole stack down in order
645/// (design §4.2's `run_application`).
646///
647/// The caller resolves and prints the page URL (from
648/// [`PageServer::local_addr`]) before calling this, so the loud "serving at"
649/// line always states the REAL bound address. This finalizes the derived bus
650/// origin allow-list from that same address before the bus binds.
651///
652/// # Errors
653///
654/// Returns the first typed failure from boot, serving, or ordered shutdown.
655/// A serve failure takes precedence in the report, but no teardown failure
656/// is ever hidden — each is logged as it happens. An unexpected bus
657/// termination at runtime surfaces as an error after a clean teardown.
658pub fn run_application(
659    mut config: FrameConfig,
660    spec: AppSpec,
661    page: PageServer,
662) -> Result<(), HostError> {
663    // The page server is already resolved and BOUND (prefer-and-walk, or
664    // stated-and-exact) by the caller, so its REAL address is known before the
665    // bus binds: fold it into the derived origin allow-list now, so the bus's
666    // WebSocket acceptor allows the served page's own origin (a stated
667    // allow-list is LAW and left untouched — see `finalize_page_origins`).
668    config.finalize_page_origins(page.local_addr());
669    let app = Application::boot(&config, spec)?;
670    // Only the process signals (and the bus liveness monitor) govern the
671    // standalone server: the embedder stop future never resolves.
672    let serve_outcome = app.serve(&config, page, std::future::pending());
673    let shutdown_outcome = app.shutdown();
674    let stop = serve_outcome?;
675    if let ServeStop::LiminalExited { detail } = stop {
676        return Err(HostError::LiminalExited { detail });
677    }
678    shutdown_outcome?;
679    tracing::info!("frame server exited cleanly");
680    Ok(())
681}