Kernel events
Reference. The ten events the kernel emits, the sink type that receives them, and the default sink's output format. For where the sink is set, see start and StartOptions; for why the kernel emits events rather than logging, see Design decisions.
KernelEvent
type KernelEvent =
| { readonly type: "building" }
| { readonly type: "startFailed"; readonly cause: unknown }
| {
readonly type: "serving";
readonly runtime: string;
readonly info: unknown;
readonly probePort: number | undefined;
}
| { readonly type: "draining"; readonly inFlight: number }
| { readonly type: "drained"; readonly report: DrainReport }
| {
readonly type: "stoppedWaiting";
readonly phase: "build" | "stop";
readonly afterMs: number | undefined;
}
| { readonly type: "stopping" }
| { readonly type: "exited" }
| {
readonly type: "teardownError";
readonly port: string;
readonly cause: unknown;
}
| { readonly type: "uncaught"; readonly cause: unknown };| Event | Fields | Emitted when |
|---|---|---|
building | — | start is called, before the probe server binds or the graph is built. Always the first event. |
startFailed | cause | Anything failed before serving: a construction Err (a ConfigInvalid naming its variables), a runtime's RuntimeStartFailed, a probe bind failure, or a defect. cause is the Err's error or the defect's cause. Emitted before stopping, so a process that never came up says why. |
serving | runtime — the runtime's name; info — whatever it published on Serving.info; probePort — what the kernel's probe listener bound, undefined when probes are off | The runtime answered Ok(serving). info is unknown because the kernel does not know a runtime's Info at the event union; probePort is its own field because the probe server is the kernel's, not the runtime's. Together they make PORT=0 / PROBE_PORT=0 readable — a process that binds an ephemeral port says which one it got. |
draining | inFlight | A signal (or requestDrain()) arrived while serving. Emitted in the same synchronous turn readiness flips false, so inFlight equals the report's inFlightAtStart. |
drained | report: DrainReport | The drain finished — by the registry going idle or by the deadline. |
stoppedWaiting | phase, afterMs | The kernel stopped waiting for a phase with no deadline of its own and reported anyway: phase: "stop" for a Serving.stop or a finaliser still running at stopTimeoutMs, phase: "build" for a graph given up on before it served. afterMs is the deadline that expired, or undefined when a second signal cut the wait short. A clean shutdown emits none, which is what makes its presence worth alerting on. |
stopping | — | The phase reached stopping: after the drain, or straight away for stop(), an uncaught exception or a startup failure. |
exited | — | The phase reached exited. Last on every ordinary path — but the phase is reached when Serving.stop returns, which is before di runs the scope's finalisers, so a stoppedWaiting for phase: "stop" can follow it when one of those outlives stopTimeoutMs. |
teardownError | port, cause | A finaliser failed as a scope closed — the application scope's (also recorded in ExitReport.teardownErrors) or a bound unit module's fork (recorded nowhere else). |
uncaught | cause | An uncaughtException or unhandledRejection was caught by the kernel's handlers (signals: true). Only the first is reported; the shutdown it triggers may produce more noise, and the report names one cause. |
serving, stopping and exited are emitted by the phase tracker as it advances, so they can never be emitted twice or out of order.
EventSink
type EventSink = (event: KernelEvent) => void;Set through StartOptions.onEvent; default stderrSink. The kernel wraps whatever it is given so that a throwing sink is swallowed — a broken reporter must not take the process down mid-shutdown, and there is nowhere left to report a broken reporter to. Two consequences: a sink that throws loses that one event silently, and a sink that wants to fail loudly cannot.
stderrSink
Writes one JSON line per event to process.stderr, JSON.stringify of the event with two adjustments:
- An
Erroranywhere in the value — acause, or a nestedcauseof one — is normalised to{ name, message, stack, cause }.JSON.stringifyskips non-enumerable properties, and anError'smessageandstackare both non-enumerable, so a bareErrorwould render the two cause-carrying events as{"cause":{}}and the default crash report would name no error at all. - A value
JSON.stringifyrefuses outright (a circular object) does not cost the whole event: the line is written as{"type":"<type>","cause":"[unserialisable]"}instead. Left to throw,safeSinkwould swallow it and the event would be reported nowhere.
A sample transcript
A signal-driven shutdown of an HTTP process with one request in flight, as stderrSink writes it:
{"type":"building"}
{"type":"serving","runtime":"http","info":{"port":3000},"probePort":9000}
{"type":"draining","inFlight":1}
{"type":"drained","report":{"inFlightAtStart":1,"completed":1,"abandoned":0}}
{"type":"stopping"}
{"type":"exited"}The same process failing to configure — PORT=abc — never reaches serving (the stack trace is elided here):
{"type":"building"}
{"type":"startFailed","cause":{"name":"ConfigInvalid","message":"HttpConfig could not be configured:\n PORT: is not a whole number: \"abc\"","stack":"Error\n at …"}}
{"type":"stopping"}
{"type":"exited"}The normalisation replaces the whole Error with the four-field object, so a TaggedError's own fields (port, issues) do not appear on the line; its message — one line per variable — is what carries them. And a cause that cannot be serialised at all:
{ "type": "uncaught", "cause": "[unserialisable]" }Writing a sink
import { start, type EventSink } from "@btravstack/core";
const events: string[] = [];
const collect: EventSink = (event) => {
events.push(event.type);
};
const app = start(OrderApi, { onEvent: collect, probes: false });A sink is synchronous and returns void; anything it must await it schedules itself. Under @btravstack/testing's bootFixture the default sink is silent and a call's own onEvent wins. Only signals is forced off; probes is merely defaulted off, so a call may still ask for { probes: { port: 0 } }.