Nothing throws
Explanation. This page explains three of the kernel's theses together —
startnever throws, every async surface is anAsyncResult, and the startup error channel is the application's own — plus the failure model and exit codes they produce. For the code table, seerunMainand exit codes; to run withoutrunMain, see Embed without runMain.
start never throws and never calls process.exit. It returns a RunningApp whose exited is an AsyncResult<ExitReport, E | RuntimeStartFailed>, and every failure route — a construction error, a runtime that refused to start, a probe port already taken, an uncaught exception, a finaliser that failed — lands in one of that value's channels.
That is what makes the kernel embeddable. A dev runner can boot two applications side by side; a test file can boot a dozen; nothing any of them does can end the process or escape as an exception into another's await. The one place a process's fate is decided is runMain, and even there it sets process.exitCode rather than calling process.exit(), so pending output flushes, an embedding host keeps control of its own lifetime, and a test can observe the code without the run ending.
Every async surface is an AsyncResult
Not only the fallible ones. AsyncResult<T, never> is this package's spelling of "async, and cannot fail" — what fromSafePromise produces — and it is used everywhere a bare Promise<void> would otherwise appear: probePort(), runtimeInfo(), Clock.sleep, FakeClock.advance, UnitRegistry.awaitIdle, TestRuntime.untilStarted, a probe server's close. The point is uniformity: every async surface awaits into a Result, so a caller never has to remember which ones did and which returned a bare value.
unthrown/prefer-async-result cannot enforce this — it flags a Promise<Result<T, E>>, and a Promise<void> is not Result-bearing — so it is a convention held by review, with exactly three exceptions, each documented where it lives:
runMainreturnsPromise<void>. Its whole job is to leave theResultworld and become an exit code. It is the boundary, and a top-levelawait runMain(...)in an entry point is the intended shape.UnitWork'sPromise<Result<T, E>>arm exists to accept a caller'sasynchandler without a wrapper at every call site;runnormalises it internally.bootFixture, in@btravstack/testing— vitest's own(ctx, use) => Promise<void>fixture protocol, which the harness does not get to choose.useis the test body: a thrown assertion failure inside it must reach the test runner, and anAsyncResultnever rejects, so wrapping it would turn a failingexpectinto aDefecta caller can forget to unwrap — a green test that asserted nothing. Its teardown rethrows a shutdownDefectso the runner sees that too.
The startup channel is the application's own
start takes a Module<X, E, Scope | Env> and returns AsyncResult<ExitReport, E | RuntimeStartFailed>. E is the module's own error type, passed through unwrapped and still typed. A ConfigInvalid from a configuration provider arrives at exited as a ConfigInvalid, not as a KernelStartupError with a cause you have to instanceof your way back out of.
Wrapping was the obvious design and it was rejected because it erases the model. Module.scoped already reports the module's E; a kernel wrapper would take a type the application spent effort naming and hand back unknown inside an envelope. The kernel adds exactly one error of its own, RuntimeStartFailed, because it is genuinely the kernel's — a port in use, a broker unreachable, a probe port taken — and it is the only error the kernel mints.
Failures, classified by phase
Each phase of a process's life has one honest channel, and the classification is what makes the exit code small.
- Startup — a modeled
Err. The application'sE, or the kernel'sRuntimeStartFailed. Reported as astartFailedevent beforestopping, so a process that never came up says why on stderr. - Wiring — a
Defect, untouched. A cycle or a duplicate provider arrives fromdias a defect and stays one. A wiring bug is not an outcome a caller branches on. - Teardown — visible, never masking. A failing finaliser cannot overwrite why the process stopped; the kernel collects each into
ExitReport.teardownErrorsand emits ateardownErrorevent. - Unit failures never reach the kernel. A handler's
Erris the runtime's to map (see The kernel maps nothing). uncaughtException/unhandledRejection— readiness false, then straight tostopping, skipping the drain. Deliberately harsher than a signal, for a reason on Draining, in three beats: after an uncaught throw the process state may be corrupt. Only the first is reported.
runMain folds those into a table:
| Code | Meaning |
|---|---|
0 | exited cleanly — nothing abandoned, no teardown error |
1 | a modeled startup Err |
2 | drained with work abandoned, or exited with teardown errors |
70 | an uncaught exception or unhandled rejection; or a defect |
78 | a ConfigInvalid — including a RuntimeStartFailed carrying one |
70 and 78 are sysexits(3)'s EX_SOFTWARE and EX_CONFIG. The two 70s are one statement — an internal software error — reached through the two channels a bug can take. 78 says "the deployment is wrong, not the code": the one startup failure an operator fixes without a rebuild, and the kernel's own PROBE_PORT earns it too, which is why a bad one is a RuntimeStartFailed for "probes" with the ConfigInvalid as its cause.
Two orderings in that table are written out rather than left implicit. A crash outranks abandoned work: the uncaught path skips the drain, so in practice drain is undefined when reason is "uncaught", but the code is decided from reason first so the precedence does not depend on that. And 2 is earned by a failed finaliser as much as by abandoned work: a connection pool that could not flush is exactly the shutdown an orchestrator must not be told succeeded, and the kernel goes to real trouble to keep those errors observable — reporting 0 over them would waste it.
The footgun runMain closes
start installs uncaughtException and unhandledRejection handlers, because it needs to know a crash happened to force readiness false and stop. And installing either suppresses Node's own default exit code of 1 — measured: a process that throws from a timer exits 1 bare and 0 with a no-op handler installed.
So an embedder that uses start without runMain, and sets no exit code of its own, gets a silent exit 0 after a crash. The process reports success to its orchestrator. This is the reason the uncaught row exists in the table at all, and why it maps to 70 rather than falling through to 0. An embedder that will not use runMain has two honest options: fold ExitReport.reason into an exit code itself —
const embed = async (): Promise<void> => {
const app = start(TickerApp, { signals: true });
const report = await app.exited;
process.exitCode = report.match({
ok: (exit) => (exit.reason === "uncaught" ? 70 : 0),
errCases: (matcher) => matcher.with(P.tag("RuntimeStartFailed"), () => 1),
defect: () => 70,
});
};— or pass signals: false, which turns off the uncaught handlers and the signal handlers together, at the cost of the signal-driven drain. One flag for both families, because both are process-global and a test harness needs them off together.
Events, not a logger
The kernel takes no logger dependency. It emits nine structured events — building, startFailed, serving, draining, drained, stopping, exited, teardownError, uncaught — to whatever onEvent sink it is given, and the default stderrSink writes one JSON line per event.
Two details of that sink are load-bearing. An Error cause is normalised to { name, message, stack, cause }, because JSON.stringify skips non-enumerable properties and a bare Error would render the two cause-carrying events as {"cause":{}}. And a cause it cannot serialise at all — a circular object — falls back to "[unserialisable]" rather than throwing, because a throwing sink is swallowed (a broken reporter must not take the process down mid-shutdown), and the crash would then be reported nowhere.
That the events go somewhere else is the point of the seam. @btravstack/observability ships the logging half of it: kernelEvents(logger) is an EventSink that writes each event as a log line on the application's own logger — startFailed and uncaught at error carrying their cause, teardownError at warn, the rest at info, with each event's own fields as attributes — so serving lands next to the request that was in flight when it did instead of in a second stream with a second shape. The OpenTelemetry half is not written. The kernel is unchanged either way: it is where the events come from, not where they go, and it still takes no logger dependency.
Where to go next
- The decisions behind the surface, including why
RuntimeStartFailedis the only kernel error: Design decisions. - The complete code table with its inputs:
runMainand exit codes.