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}