frame-host 0.2.0

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
//! 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. Serving then binds the page server;
//! ordered teardown runs announcer intake close, document binding stop,
//! component ordered stop (whose transitions the still-connected announcer
//! publishes), announcer 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 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, serve};
use crate::spec::AppSpec;

/// 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>,
}

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())?;

        if let Err(error) = runtime.install(spec.components) {
            // Best-effort cleanup so a failed boot does not leak a process
            // tree; the original failure is what gets reported.
            runtime.abandon_after_boot_failure();
            return Err(error);
        }

        // The application's own post-start proof (design §4.2), before the
        // bus boots and before anything serves.
        if let Err(source) = (spec.readiness)(runtime.registry()) {
            runtime.abandon_after_boot_failure();
            return Err(HostError::Application {
                stage: "readiness",
                source,
            });
        }

        // 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"
                    );
                }
                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, "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,
                    "announcer connect",
                );
                return Err(error);
            }
        };

        // 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, "fact announcement");
            return Err(error);
        }

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

    /// Serves the page until SIGTERM, SIGINT, `external_stop` resolving, or
    /// a bus liveness failure — blocking. The announcer pump goes live as
    /// serving begins: the retained boot transitions and queued facts
    /// publish first, then live events as they happen.
    ///
    /// # Errors
    ///
    /// Returns typed failures from starting the announcer pump, building
    /// the async runtime, binding the page server, 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> {
        if let Some(announcer) = &self.announcer {
            announcer.start(self.components.clone())?;
        }
        let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .map_err(|source| HostError::AsyncRuntime { source })?;
        tokio_runtime.block_on(serve(
            config,
            &self.liminal,
            self.bus_endpoint.clone(),
            external_stop,
        ))
    }

    /// 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");
        }

        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?;
        liminal_shutdown?;
        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,
    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"
        );
    }
}

/// 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(())
}