frame-host 0.2.1

Frame host server and embedding seam — boots an application's frame-core component tree with an embedded liminal bus, announces the host's real application events on the bus, and serves the built frame page
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
//! The embedding seam's orchestration (design §4.1/§4.2): boot the full
//! stack around an application's [`AppSpec`], serve until a stop, tear the
//! whole stack down in order.
//!
//! Boot order: component runtime → component register/start → the
//! application's readiness proof → embedded bus boot → document binding
//! (when configured) → application-event announcer connect → the
//! application's fact announcements (retained by [`crate::truth::AppTruth`]
//! the instant they are accepted, independent of the bus). Serving BINDS the
//! page server FIRST, then starts the announcer pump draining its retained
//! backlog (2026-07-21 boot-visibility fix — see [`crate::truth`] for the
//! defect this order used to have and the snapshot endpoint that closes the
//! remaining race even so); ordered teardown runs announcer intake close,
//! document binding stop, component ordered stop (whose transitions the
//! still-connected announcer publishes), announcer join, app-truth recorder
//! join, and the bus's graceful drain — every failure typed, logged, and
//! never hidden behind an earlier one.
//!
//! frame-host's own binary is the first consumer of this seam
//! ([`crate::app`]); the generated scaffold is the second.

use std::collections::HashSet;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::thread::JoinHandle;

use frame_core::component::ComponentId;
use frame_core::event::LifecycleSubscription;

use crate::announcer::Announcer;
use crate::config::FrameConfig;
use crate::doc_binding::DocBinding;
use crate::embedded::EmbeddedLiminal;
use crate::error::HostError;
use crate::runtime::{EventLoggerHandle, HostRuntime};
use crate::serve::{ServeStop, bind};
use crate::spec::{AppSpec, ComponentInstall, ReadinessProbe};
use crate::truth::AppTruth;

/// Bounded buffer between the registry's lifecycle stream and the
/// announcer. Overflow drops oldest-first and is reported loudly by the
/// announcer pump — the same posture as the console logger's buffer.
const ANNOUNCER_EVENT_BUFFER: usize = 256;

/// A booted application stack: the component runtime with the application's
/// components running, the embedded bus, the optional document binding, and
/// the application-event announcer (absent — loudly — only when the config
/// declares no `[frame].channel`).
pub struct Application {
    runtime: HostRuntime,
    logger: EventLoggerHandle,
    liminal: EmbeddedLiminal,
    doc_binding: Option<DocBinding>,
    announcer: Option<Announcer>,
    bus_endpoint: String,
    components: HashSet<ComponentId>,
    /// Process-lifetime application-truth recorder (2026-07-21
    /// boot-visibility fix): every lifecycle transition and announced fact
    /// this process has produced, served at
    /// `crate::server::APP_STATUS_ROUTE` so a client joining at any moment
    /// observes the full boot history over plain HTTP — no bus race, no
    /// out-of-band subscribe-before-serve shortcut required. See
    /// [`crate::truth`] for the full defect/fix writeup.
    truth: Arc<AppTruth>,
    /// Join handle for the dedicated recorder thread ([`AppTruth::spawn`]).
    truth_recorder: JoinHandle<()>,
}

impl Application {
    /// Boots the full stack around the application's spec, in order. A boot
    /// failure tears down whatever already booted so no component is left
    /// running; the first typed failure is what gets reported.
    ///
    /// # Errors
    ///
    /// Returns the first typed failure from spec validation, runtime
    /// composition, component install, the application's readiness proof,
    /// the embedded bus boot, the document binding, the announcer connect
    /// (a typed boot failure — never a silently event-less page), or the
    /// application's fact announcements.
    pub fn boot(config: &FrameConfig, spec: AppSpec) -> Result<Self, HostError> {
        if spec.components.is_empty() {
            return Err(HostError::ConfigContract {
                detail: "the application spec installs no components: a host with nothing to \
                         host is a composition error, not an empty stack"
                    .to_owned(),
            });
        }
        let components: HashSet<ComponentId> = spec
            .components
            .iter()
            .map(|install| install.meta.id)
            .collect();

        let mut runtime = HostRuntime::new(spec.policy)?;

        // The announcer's lifecycle subscription is taken BEFORE install so
        // the boot transitions are retained for publication once the pump
        // goes live; the console logger subscribes before install for the
        // same reason.
        let announcer_subscription: Option<LifecycleSubscription> = match &config.frame.channel {
            Some(_) => {
                let capacity = NonZeroUsize::new(ANNOUNCER_EVENT_BUFFER)
                    .ok_or(HostError::SynchronizationPoisoned)?;
                Some(runtime.registry().subscribe(capacity)?)
            }
            None => None,
        };
        let logger = runtime.spawn_event_logger(components.clone())?;

        // The application-truth recorder (2026-07-21 boot-visibility fix):
        // a THIRD dedicated lifecycle subscription, taken here for the same
        // reason as the two above — so the retained boot transitions are
        // never missed. Recording is independent of whether
        // `[frame].channel` is declared: this is host-local truth served
        // over plain HTTP (`/frame/app/status.json`), not a bus publish.
        // See `crate::truth` for the full defect/fix writeup.
        let (truth, truth_recorder) = AppTruth::spawn(runtime.registry(), components.clone())?;

        // Installs every declared component, then runs the application's
        // post-start readiness proof (design §4.2), before the bus boots and
        // before anything serves; either failure best-effort abandons
        // whatever installed so far so a failed boot never leaks a process
        // tree.
        install_and_verify_ready(&mut runtime, spec.components, spec.readiness)?;

        // Boot the embedded bus component in-process (its own threads; no
        // child process). A bind/boot failure exits with the failing
        // component named — never a half-up stack. The components already
        // running are torn down first so they are not abandoned.
        let liminal = match EmbeddedLiminal::boot(&config.bus) {
            Ok(liminal) => liminal,
            Err(error) => {
                if let Err(teardown) = runtime.shutdown(logger) {
                    tracing::error!(
                        %teardown,
                        "frame-core teardown after bus boot failure also failed"
                    );
                }
                join_truth_recorder(truth_recorder, "bus boot failure");
                return Err(error);
            }
        };
        let bus_endpoint = liminal.websocket_endpoint();
        tracing::info!(
            endpoint = %bus_endpoint,
            "embedded bus component booted; /frame/config.json advertises its real bound WebSocket address as busEndpoint"
        );

        // The document-service binding when [document] is configured.
        let doc_binding = match DocBinding::boot(&liminal, config) {
            Ok(binding) => binding,
            Err(error) => {
                teardown_after_boot_failure(
                    None,
                    liminal,
                    runtime,
                    logger,
                    truth_recorder,
                    "document-service",
                );
                return Err(error);
            }
        };

        // The application-event announcer (design §4.3): a SEPARATE native
        // participant connection to the embedded server's real bound TCP
        // address. The page server itself carries zero feed bytes (ruling
        // D3); the announcer is a node on the bus, not a proxy in front of
        // it. A failed connect is a typed boot failure.
        let announcer = match connect_announcer(config, &liminal, announcer_subscription) {
            Ok(announcer) => announcer,
            Err(error) => {
                teardown_after_boot_failure(
                    doc_binding,
                    liminal,
                    runtime,
                    logger,
                    truth_recorder,
                    "announcer connect",
                );
                return Err(error);
            }
        };
        // Mirror every fact this announcer accepts into the app-truth
        // snapshot (2026-07-21 boot-visibility fix) — attached BEFORE the
        // boot fact announcement below, so it is retained here too.
        if let Some(announcer) = &announcer {
            announcer.attach_truth(Arc::clone(&truth));
        }

        // The application's own fact announcements, through the connected
        // announcer. Queued facts publish once the pump goes live at serve
        // time, after the retained boot transitions.
        if let Err(error) = announce_boot_facts(spec.announce, &runtime, announcer.as_ref()) {
            teardown_after_boot_failure(
                doc_binding,
                liminal,
                runtime,
                logger,
                truth_recorder,
                "fact announcement",
            );
            return Err(error);
        }

        Ok(Self {
            runtime,
            logger,
            liminal,
            doc_binding,
            announcer,
            bus_endpoint,
            components,
            truth,
            truth_recorder,
        })
    }

    /// Serves the page until SIGTERM, SIGINT, `external_stop` resolving, or
    /// a bus liveness failure — blocking.
    ///
    /// SERVE-ORDER FIX (2026-07-21 boot-visibility fix, [`crate::truth`]):
    /// the HTTP+WS surface binds FIRST; the announcer pump only starts
    /// draining its retained boot backlog (transitions, then queued facts,
    /// then live events) once that surface is reachable. Before this fix the
    /// pump started as this function's first action — before the tokio
    /// runtime even existed — so the entire backlog drained before
    /// `/frame/config.json` (the only doctrine-sanctioned way a browser
    /// discovers the bus address) was fetchable at all; no real browser
    /// could ever win that race. `/frame/app/status.json` (also served the
    /// instant this binds) closes the remaining gap: a client that still
    /// arrives after the live drain observes the full boot history there
    /// instead of racing the bus.
    ///
    /// # Errors
    ///
    /// Returns typed failures from binding the page server, starting the
    /// announcer pump, building the async runtime, registering signal
    /// handlers, or the accept loop. The caller still owns the stack and
    /// must drive [`Self::shutdown`].
    pub fn serve(
        &self,
        config: &FrameConfig,
        external_stop: impl Future<Output = ()> + Send + 'static,
    ) -> Result<ServeStop, HostError> {
        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .map_err(|source| HostError::AsyncRuntime { source })?;
        // SERVE-ORDER FIX (2026-07-21 boot-visibility fix): the HTTP+WS
        // surface binds FIRST, inside `serve` below; the announcer pump only
        // starts draining its retained boot backlog AFTER that surface is
        // reachable — moved out of the position this function used to start
        // it in (its former first line, before the tokio runtime even
        // existed). A real browser fetches `/frame/config.json` and only
        // then subscribes; starting the pump before the surface bound meant
        // the entire backlog drained before that fetch was even possible.
        // See `crate::truth` for the snapshot half of this fix, which closes
        // the remaining gap even when a client races the pump regardless.
        tokio_runtime.block_on(async {
            let bound = bind(
                config,
                &self.liminal,
                self.bus_endpoint.clone(),
                &self.truth,
            )
            .await?;
            if let Some(announcer) = &self.announcer {
                announcer.start(self.components.clone())?;
            }
            bound.serve(external_stop).await
        })
    }

    /// Ordered teardown, always running every step and hiding no failure:
    /// announcer intake closes first, the document binding stops (its pump
    /// rides the bus), the components stop and remove in order (their
    /// transitions publish through the still-connected announcer, whose
    /// pump exits on the final Removed), the announcer joins, then the bus
    /// drains gracefully.
    ///
    /// # Errors
    ///
    /// Returns the first typed teardown failure by stage precedence
    /// (document, components, announcer, bus); every later failure is
    /// still executed and logged before the first is returned.
    pub fn shutdown(self) -> Result<(), HostError> {
        // Design §4.1 teardown order, step 1: the announcer stops taking
        // new facts. Its pump keeps publishing until the final Removed.
        if let Some(announcer) = &self.announcer {
            announcer.close_intake();
        }

        let doc_shutdown = match self.doc_binding {
            None => Ok(()),
            Some(binding) => binding.shutdown(),
        };
        if let Err(ref error) = doc_shutdown {
            tracing::error!(%error, "document-service shutdown failed");
        }

        let host_shutdown = self.runtime.shutdown(self.logger);
        if let Err(ref error) = host_shutdown {
            tracing::error!(%error, "frame-core ordered shutdown failed");
        }

        // Join the announcer only after a completed component shutdown has
        // published every Removed transition (the pump's exit condition);
        // after a FAILED component shutdown the pump may never observe them,
        // so the announcer is dropped un-joined — loudly — instead of
        // hanging the teardown on it.
        let announcer_shutdown = match self.announcer {
            None => Ok(()),
            Some(announcer) => {
                if host_shutdown.is_ok() {
                    match announcer.stop() {
                        Ok(None) => Ok(()),
                        Ok(Some(death)) => {
                            tracing::warn!(
                                death,
                                "the application-event announcer had died at runtime; its \
                                 recorded death detail is reported here at teardown"
                            );
                            Ok(())
                        }
                        Err(error) => Err(error),
                    }
                } else {
                    tracing::error!(
                        "component shutdown failed; abandoning the announcer pump un-joined \
                         rather than hanging teardown on transitions that will never arrive"
                    );
                    drop(announcer);
                    Ok(())
                }
            }
        };
        if let Err(ref error) = announcer_shutdown {
            tracing::error!(%error, "announcer shutdown failed");
        }

        // The app-truth recorder observes the SAME expected-component-Removed
        // exit condition as the console logger, published by the ordered
        // component shutdown above — join it only once that has actually
        // happened; a FAILED component shutdown means those Removed
        // transitions may never arrive, so the recorder is abandoned
        // un-joined — loudly — rather than hanging teardown on it (the exact
        // posture the announcer's own join takes just above).
        let truth_shutdown = if host_shutdown.is_ok() {
            self.truth_recorder
                .join()
                .map_err(|_| HostError::TruthRecorderPanicked)
        } else {
            tracing::error!(
                "component shutdown failed; abandoning the app-truth recorder un-joined rather \
                 than hanging teardown on transitions that will never arrive"
            );
            drop(self.truth_recorder);
            Ok(())
        };
        if let Err(ref error) = truth_shutdown {
            tracing::error!(%error, "app-truth recorder shutdown failed");
        }

        let liminal_shutdown = self.liminal.shutdown();
        if let Err(ref error) = liminal_shutdown {
            tracing::error!(%error, "embedded bus graceful shutdown failed");
        }

        doc_shutdown?;
        host_shutdown?;
        announcer_shutdown?;
        truth_shutdown?;
        liminal_shutdown?;
        Ok(())
    }
}

/// Installs every declared component, then runs the application's readiness
/// proof — extracted from [`Application::boot`] purely to keep that
/// function's line count within the workspace's clippy budget; the boot-time
/// cleanup posture is unchanged: either failure best-effort abandons
/// whatever installed so far (`abandon_after_boot_failure`) before the
/// typed error propagates, exactly as the two checks did inline.
///
/// # Errors
///
/// Returns the runtime's typed install failure, or
/// [`HostError::Application`] carrying the readiness proof's typed refusal.
fn install_and_verify_ready(
    runtime: &mut HostRuntime,
    components: Vec<ComponentInstall>,
    readiness: ReadinessProbe,
) -> Result<(), HostError> {
    if let Err(error) = runtime.install(components) {
        runtime.abandon_after_boot_failure();
        return Err(error);
    }
    if let Err(source) = readiness(runtime.registry()) {
        runtime.abandon_after_boot_failure();
        return Err(HostError::Application {
            stage: "readiness",
            source,
        });
    }
    Ok(())
}

/// Connects the application-event announcer over the embedded server's real
/// bound TCP wire address when `[frame].channel` is declared; otherwise the
/// announcer is disabled LOUDLY (documented: the served page then observes
/// no host events on any channel).
///
/// # Errors
///
/// Returns [`HostError::AnnouncerConnect`] — a typed boot failure, never a
/// silently event-less page.
fn connect_announcer(
    config: &FrameConfig,
    liminal: &EmbeddedLiminal,
    subscription: Option<LifecycleSubscription>,
) -> Result<Option<Announcer>, HostError> {
    let (Some(channel), Some(subscription)) = (&config.frame.channel, subscription) else {
        tracing::warn!(
            "no [frame].channel is declared: the application-event announcer is disabled and \
             the served page will observe no host events on any channel; declare \
             [frame].channel to announce lifecycle transitions, capability denials, and \
             application facts"
        );
        return Ok(None);
    };
    let auth_token = config
        .bus
        .auth
        .as_ref()
        .map(|auth| auth.token.clone().into_bytes());
    Announcer::connect(
        &liminal.tcp_addr().to_string(),
        auth_token.as_deref(),
        channel.clone(),
        subscription,
    )
    .map(Some)
}

/// Runs the application's boot-time fact announcements through the
/// connected announcer, or skips them LOUDLY when no announcer exists.
///
/// # Errors
///
/// Returns [`HostError::Application`] carrying the application's own typed
/// refusal.
fn announce_boot_facts(
    announce: crate::spec::FactAnnouncement,
    runtime: &HostRuntime,
    announcer: Option<&Announcer>,
) -> Result<(), HostError> {
    let Some(announcer) = announcer else {
        tracing::warn!("application facts are not announced: no [frame].channel is declared");
        return Ok(());
    };
    announce(runtime.registry(), announcer).map_err(|source| HostError::Application {
        stage: "announce",
        source,
    })
}

/// Best-effort ordered teardown after a boot-stage failure, each failure
/// logged loudly; the original boot failure is what the caller reports.
fn teardown_after_boot_failure(
    doc_binding: Option<DocBinding>,
    liminal: EmbeddedLiminal,
    runtime: HostRuntime,
    logger: EventLoggerHandle,
    truth_recorder: JoinHandle<()>,
    failed_stage: &str,
) {
    if let Some(binding) = doc_binding
        && let Err(teardown) = binding.shutdown()
    {
        tracing::error!(
            %teardown,
            failed_stage,
            "document-service teardown after boot failure also failed"
        );
    }
    if let Err(teardown) = liminal.shutdown() {
        tracing::error!(
            %teardown,
            failed_stage,
            "embedded bus teardown after boot failure also failed"
        );
    }
    if let Err(teardown) = runtime.shutdown(logger) {
        tracing::error!(
            %teardown,
            failed_stage,
            "frame-core teardown after boot failure also failed"
        );
    }
    join_truth_recorder(truth_recorder, failed_stage);
}

/// Best-effort join of the app-truth recorder thread after a boot-stage
/// failure. The recorder's exit condition (every expected component's
/// Removed transition) is the SAME one the console logger relies on,
/// published by the `runtime.shutdown`/`abandon_after_boot_failure` call
/// just above each of this function's call sites — so by the time this
/// runs, the join is expected to complete promptly. A panicked recorder is
/// logged loudly, never hidden behind the original boot failure the caller
/// reports.
fn join_truth_recorder(truth_recorder: JoinHandle<()>, failed_stage: &str) {
    if truth_recorder.join().is_err() {
        tracing::error!(
            failed_stage,
            "app-truth recorder thread panicked during teardown after a boot failure"
        );
    }
}

/// Loads nothing itself: boots the full stack around the supplied
/// application spec from an already-loaded config, serves until a shutdown
/// signal (or a bus liveness failure), then tears the whole stack down in
/// order (design §4.2's `run_application`).
///
/// # Errors
///
/// Returns the first typed failure from boot, serving, or ordered shutdown.
/// A serve failure takes precedence in the report, but no teardown failure
/// is ever hidden — each is logged as it happens. An unexpected bus
/// termination at runtime surfaces as an error after a clean teardown.
pub fn run_application(config: &FrameConfig, spec: AppSpec) -> Result<(), HostError> {
    let app = Application::boot(config, spec)?;
    // Only the process signals (and the bus liveness monitor) govern the
    // standalone server: the embedder stop future never resolves.
    let serve_outcome = app.serve(config, std::future::pending());
    let shutdown_outcome = app.shutdown();
    let stop = serve_outcome?;
    if let ServeStop::LiminalExited { detail } = stop {
        return Err(HostError::LiminalExited { detail });
    }
    shutdown_outcome?;
    tracing::info!("frame server exited cleanly");
    Ok(())
}