The Runtime contract
Reference. The service behind a runtime port, the host it is handed, the unit-of-work types, the ambient record and the clock. For writing one, see Write a runtime; for why the kernel maps nothing, see The kernel maps nothing.
Runtime<Resolves, Info>
type Runtime<Resolves extends AnyPort = never, Info = never> = {
readonly name: string;
readonly resolves: readonly Resolves[];
readonly start: (
host: RuntimeHost<Resolves>,
) => AsyncResult<Serving<Info>, RuntimeStartFailed>;
readonly units?: Readonly<Record<string, AnyUnitModule | undefined>>;
};| Member | Semantics |
|---|---|
name | Reported on the serving event. |
resolves | The port classes the runtime resolves from host.ctx. start's gate checks them against the module's exports at the call site. Every shipped starter declares resolves: [] — what its handlers read is its provider's business, through di — so this is the general contract, used by testRuntime and hand-rolled runtimes. |
start | Called once, after the graph is built. Ok(serving) moves the phase to serving; Err(RuntimeStartFailed) is a startup failure the kernel reports through exited. |
units | Optional: the unit modules the runtime forks, by kind ({ message: … }, one per HTTP scheme). Read only to apply a test's UnitOverrides: the override names a kind, fork is handed a module, and this maps one onto the other. A runtime that omits it refuses every unit override at boot. See UnitOverrides. |
Resolves is parameterised by port classes (AnyPort) but hands out Context<InstanceType<Resolves>>, because di parameterises Context<in R> by port instance types. InstanceType<never> is never, so a runtime that resolves nothing gets a context it can read nothing from.
A UnitHost.fork module's own needs are not part of this contract: a fork module is forked over the application context, so its needs are exactly what a starter's own needs channel already asks the composition root to supply, and di's ordinary UNSATISFIED DEPENDENCIES gate is what refuses a root that does not — the same gate a starter's own needs: [Logger] triggers, not a Runtime type parameter.
RuntimeHost<Resolves>
type RuntimeHost<Resolves extends AnyPort> = {
readonly ctx: Context<InstanceType<Resolves>>;
readonly run: RunUnit<Resolves>;
};ctx is the application context — the module's exports, never a port a fork module provides, which exists only in the Context fork hands back. run is the kernel's unit registry, closed over that context.
UnitHost<Resolves>
type UnitHost<Resolves extends AnyPort> = {
readonly ctx: Context<InstanceType<Resolves>>;
readonly fork: <UnitX, N, Seeded extends AnyPort = never>(
module: Module<UnitX, never, N> &
DependencyGate<Exclude<N, InstanceType<Resolves> | InstanceType<Seeded> | Scope>>,
seed: readonly SeedEntry<Seeded>[],
) => AsyncResult<Context<InstanceType<Resolves> | UnitX | InstanceType<Seeded>>, never>;
};What a unit's work callback is handed instead of a bare Context: ctx is the same application context RuntimeHost.ctx is, and fork(module, seed) is the one way to open the unit's own scope — building module over ctx plus seed and handing the forked Context back. The kernel closes that scope when the unit settles: inside the registry's unit, so the unit is not counted closed until the fork's finalisers have run, and inside the unit's ambient record, so a teardown log line carries the unit's ids. A construction failure rides the unit's defect path — the caller's fork(...) call settles as a Defect rather than hanging. A unit forks once; a second fork call is a defect too, and so is one made after the unit has settled — nothing awaits that scope's teardown.
The DependencyGate intersection is how a seed subtracts a need: what the module still owes (N) is checked after the ports the application context already resolves, the ports seed supplies, and Scope have been excluded, so a module needing only a seeded port compiles and one needing a port neither side supplies is refused at the fork call.
RunUnit<Resolves>
type RunUnit<Resolves extends AnyPort> = <T, E>(
meta: UnitMeta,
work: (unit: UnitHost<Resolves>, signal: AbortSignal) => ReturnType<UnitWork<T, E>>,
) => AsyncResult<T, E>;Submit one piece of work as a unit. The kernel counts it towards the drain, opens its ambient record, hands it an AbortSignal (fired at the drain deadline, or at once when the drain is skipped — the same object is on the record as signal, for a runtime whose work callback is a library's next()) and gives the work's own Result straight back — mapping that outcome to a transport is the runtime's job. A runtime that wants a per-unit scope calls unit.fork(...) itself, from inside work, at the moment it holds the unit's own input.
The signal also aborts once the unit settles, after its scope has closed, with an AbortError whose message is "The unit has settled" — the kernel's own aborts keep the default reason. Work a runtime leaves running past its unit is told to stop that way: @btravstack/http-server settles a unit on the response's 'close', so a client that disconnects aborts the handler it left behind. A unit that settled is closed, so this abort is never counted abandoned, and a unit module's onStop still reads only the kernel's own aborts on signal.aborted.
Serving<Info>
type Serving<Info = never> = {
readonly drain: (signal: AbortSignal) => AsyncResult<void, never>;
readonly stop: () => AsyncResult<void, never>;
readonly info?: Info;
readonly stopped?: () => AsyncResult<void, never>;
};| Member | Semantics |
|---|---|
drain | "Stop accepting." Returns void, not a DrainReport — only the kernel can see the unit registry, so the kernel owns the accounting. signal fires when the kernel's deadline passes; a runtime never does arithmetic on time. A runtime whose transport waits (a Temporal Worker's run()) must race the signal. |
stop | Tear the transport down. Called after the drain, or straight away when the drain is skipped. |
info | What the runtime publishes about itself once serving, read back through RunningApp.runtimeInfo(). Info defaults to never, so the field is unwritable and optional with no ceremony. It is deliberately not a port number: { port } for an HTTP runtime, { taskQueue, namespace } for a Temporal one. |
stopped | Optional. Settles when the runtime has stopped serving on its own account — see below. |
All three functions are typed AsyncResult<void, never>; never empties the error channel only. A drain that throws internally arrives at the kernel as a Defect. An Ok or Defect from stopped requests the same shutdown.
When a runtime stops on its own
Without it a runtime has no way to say it has given up, and the lifecycle only ever moves on a signal or a stop() call. So a Temporal worker whose run() rejected mid-flight, or an AMQP consumer the server cancelled with nothing re-subscribing, left the process alive and /readyz answering 200 — a pod in a Service's endpoints, consuming nothing. The orchestrator cannot learn that by itself: liveness answers from building onward, and readiness is the lifecycle's state rather than the transport's.
The kernel races it against its own shutdown deferred and reports reason: "runtimeStopped", unexpectedRuntimeStop: true, which runMain exits 1 for — a restart, the right answer to a transport that has given up. That holds when stop() then answers Ok. A stop() that returns the failure makes exited a defect, which runMain exits 70 for: a Temporal worker whose run() rejected recovers the defect for stopped but returns it again from stop(), so that path exits 70, not 1.
A runtime that cannot know omits it, and one with nothing to say withdraws. An HTTP server that is listening is serving; there is no third state, and a field it had to write OkAsync() into would be a promise it could not keep. A runtime that CAN report this returns an AsyncResult that never settles while nothing is wrong — and, crucially, after a stop the kernel itself asked for: settling on the ordinary path would race every clean shutdown.
It says nothing about why. The runtime that knows writes that line itself, through Observers; this channel exists so the process ends, not so the reason travels.
@btravstack/temporal-worker wires it; @btravstack/amqp-worker does not, and that is a gap rather than a decision — @amqp-contract/worker reports a server-initiated consumer cancel as a log line and exposes no signal a runtime can race. What that package does now is route the library's diagnostics to Observers, so the cancel is at least visible; closing the gap properly needs a hook upstream.
RuntimePort, RuntimeInfoOf and RuntimeStartFailed
const RuntimePort = Port("Runtime"); // generic: no fixed service
class HttpRuntime extends RuntimePort<Runtime<never, HttpInfo>> {}RuntimePort is the one port the kernel resolves its runtime from. Left generic on purpose: every runtime port is one id at runtime — a process boots exactly one — while each carries its own Resolves/Info in the type. A runtime package declares its own class over it and ships a module providing it. RuntimeInfoOf<X> reads the Info back out of a module's exports; it is the only helper type of that family the package exports.
class RuntimeStartFailed extends TaggedError("RuntimeStartFailed")<{
readonly runtime: string;
readonly cause: unknown;
}> {}
// message: `the ${runtime} runtime failed to start`The one error the kernel mints. A runtime answers it from start; the kernel answers it with runtime: "probes" when the probe server cannot bind, and with runtime: "kernel" when one of its own variables is malformed — PROBE_PORT, PRE_DRAIN_DELAY_MS, DRAIN_TIMEOUT_MS — its cause then a ConfigInvalid naming every one of them that was wrong.
traceIdOfTraceparent
const traceIdOfTraceparent: (header: string) => string | undefined;The trace-id field of a W3C traceparent header, and nothing else of it. Reach for it when your runtime carries an inbound trace: @btravstack/http-server reads it off traceparent (outranking x-request-id) and @btravstack/amqp-worker off the message headers (outranking messageId).
The parent's span id is dropped, deliberately: UnitMeta.traceId is a correlation id, not a span context, and half-carrying one would suggest a parent-child edge nothing here maintains. What comes back is undefined for anything the specification calls invalid — a malformed header, an all-zero trace id, an all-zero parent id (well-formed, and naming no span), and the reserved version ff — so a runtime falls back to whatever it uses when no trace arrives, rather than adopting one that means nothing. @btravstack/http-server falls back to x-request-id and then to the id it minted; @btravstack/amqp-worker falls back to the delivery's messageId, then correlationId, then its own. A higher version may append fields the parser has never seen, and those are read rather than refused — version 00 stays exact.
Pair it with the rule your own headers need: adopt only a non-blank inbound id, because UnitMeta.traceId defaults to meta.id when it is nullish and "" is not.
releasedBy
const releasedBy: (
signal: AbortSignal,
running: AsyncResult<void, never>,
) => AsyncResult<void, never>;running, but no later than the kernel's drain deadline. Reach for it in Serving.drain when the work you await settles on somebody else's clock — Temporal's shutdownForceTime, a broker library's close() — and therefore cannot honour signal itself. Without it, Serving.stop can outlive drainTimeoutMs however long that other clock takes.
drain: (signal) => releasedBy(signal, running);The losing branch's Result is dropped, which is the point rather than an oversight: once the deadline wins, the kernel has already moved on and the eventual outcome has no consumer left. What that costs is the runtime's own business — an un-acked AMQP delivery is redelivered, so abandoning one repeats work rather than losing it, while a Temporal activity is retried on another worker.
It is deliberately Clock-agnostic: there is no duration in it, only a signal, so it behaves identically under @btravstack/testing's fake clock. Racing work against a timeout is a different primitive and belongs on Clock — the kernel's own drain uses clock.sleep for exactly that, so a fake clock can control it. The two look alike and must not be folded together.
A worker's unit plumbing
type AnyUnitModule = Module<never, never, unknown>;
const dispatchUnit: <T, E>(dispatch: {
readonly host: RuntimeHost<never>;
readonly observers: readonly ((operation: Operation) => Settle)[];
readonly operation: Operation;
readonly meta: UnitMeta;
readonly unit: AnyUnitModule | undefined;
readonly seed: readonly [AnyPort, unknown];
readonly next: (overrides?: never) => AsyncResult<T, E>;
}) => AsyncResult<T, E>;
const withUnitRecord: (
record: Readonly<Record<string, AnyPort>>,
implementation: (helpers: never, input: never) => unknown,
) => (
helpers: { readonly context?: Readonly<Record<string | symbol, unknown>> },
input: never,
) => unknown;What @btravstack/amqp-worker and @btravstack/temporal-worker share, for a runtime whose work callback is a library's middleware next(). dispatchUnit is the middleware's body: it observes operation, opens one unit, forks unit seeded with seed when one is bound, and hands next the forked context. withUnitRecord wraps one (helpers, input) implementation so it reads its piece's declared record off helpers.context.unit, resolved lazily out of that fork — empty when no unit module is bound. The two meet under a symbol only this module knows, so neither worker carries the key.
AnyUnitModule is the bound every starter's unit option constrains its type parameter to. Beside it, UnitNeedsOf<Unit, Seeded> is what a bound module still owes less Scope and the seeded port — with Env replaced by a port nothing provides, so start refuses a unit that reads configuration with UNIT READS CONFIGURATION — Env is application-scoped; UnitRecordOf<U> the record a piece reads, and UnitGate<Unit, Declared> the "UNIT DOES NOT PROVIDE — …" refusal a worker's sugar rides on its options.
const mintPiece: (
port: AnyPort,
withUnit: (record: Readonly<Record<string, AnyPort>>, entry: unknown) => unknown,
) => (options: {
readonly inject?: Readonly<Record<string, AnyPort>>;
readonly unit?: Readonly<Record<string, AnyPort>>;
readonly sync: (services: never) => unknown;
}) => unknown;
const composeByPrefix: (
port: AnyPort,
prefix: string,
withUnit: (record: Readonly<Record<string, AnyPort>>, entry: unknown) => unknown,
) => (first: unknown) => unknown;The runtime halves of a worker's piece factory and its composing provider: AmqpHandler / TemporalWorkflowActivities mint through mintPiece, and AmqpHandlers / TemporalActivities through composeByPrefix, whose array arm keys each piece by its port id less prefix and whose record arm wraps every entry sync answers. Both are untyped on purpose — the starter states the types, these carry the body. Refuse<T, Marker, Detail> is the refused-array shape their composing arms report an uncovered key through, as long as the array the caller wrote so the marker lands on its last element.
UnitOverrides
The set port a test's providers reach a unit module through — @btravstack/testing's overridden(root, providers, { unit }) is its one contributor, and a production root never names it. Each member is a record of kind → override-branded providers. Before runtime.start, the kernel maps each kind onto the module Runtime.units binds for it and wraps that module so its overrides replace their bases when the unit is forked; a fork of any other module is untouched.
Four Defects at boot, all before the runtime starts, which is where a root-level override's WiringDefect lands too:
[core] unit override for kind "user", which runtime "http" binds no module for
[core] unit override for port "OrderRepository" in kind "user" with nothing to override — its module no longer provides it
[core] two unit overrides registered for port "OrderRepository" in kind "user"
[core] unit override for kind "user", whose module kind "session" binds too — an override cannot reach one without the otherThe third is di's own duplicate-override rule, checked here so that nested overridden calls naming one port in one kind fail at boot rather than at the first fork. The fourth exists because UnitHost.fork is handed a module, never a kind: the substitution is keyed by module identity, so one module bound under two kinds could not be overridden under one alone.
Units of work
type UnitMeta = {
readonly kind: string;
readonly id: string;
readonly traceId?: string;
readonly tenantId?: string;
};
type UnitWork<T, E> = (
signal: AbortSignal,
) => AsyncResult<T, E> | Promise<Result<T, E>> | Result<T, E>;
type UnitRegistry = {
readonly run: <T, E>(
meta: UnitMeta,
work: UnitWork<T, E>,
) => AsyncResult<T, E>;
readonly inFlight: () => number;
readonly closed: () => number;
readonly abortAll: () => void;
readonly awaitIdle: () => AsyncResult<void, never>;
};
type UnitRecord = {
readonly unitId: string;
readonly traceId: string;
readonly tenantId: string | undefined;
readonly signal: AbortSignal;
};
const currentUnit: () => UnitRecord | undefined;
type UnitOutcome = "ok" | "error";
const unitOutcome: () => UnitOutcome | undefined;| Name | Semantics |
|---|---|
UnitMeta | What a runtime says about one unit as it submits it. kind is the category ("http", "tick", "job"); id identifies this unit. traceId defaults to id. |
UnitWork | The work callback. The Promise<Result> arm exists to accept a caller's async handler — the one place the package accepts a bare Promise on purpose. Whatever Result it settles is what run hands back; a throw becomes a Defect. |
UnitRegistry | The kernel's own accounting, exposed as a type. closed() is monotonic; awaitIdle() answers about the registry at the instant it is called and is what beat 3 of the drain races. |
UnitRecord | The ambient record, opened in an AsyncLocalStorage store for the unit's whole extent. unitId is a UUID minted per unit, so it is unique across replicas too; traceId is the correlation id; signal is the same AbortSignal UnitWork receives as its argument. |
currentUnit() | The ambient read; undefined outside a unit. Its legitimate readers are infrastructure adapters (a logger, an OTel exporter, a database adapter) — see Read the ambient unit from an adapter. Not enforced by lint today. |
unitOutcome() | How the current unit's work settled — error for an Err, a Defect and a throw alike — for the code that runs after it: a unit module's onStop, which tears down inside the record but is handed only its own service. undefined outside a unit and while the work runs. It is the kernel's outcome, not the transport's: an HTTP unit that answered a 500 settled ok. Whether the unit was aborted is the record's signal. |
Clock and systemClock
type Clock = {
readonly now: () => number;
readonly sleep: (
ms: number,
signal?: AbortSignal,
) => AsyncResult<void, never>;
};systemClock is Date.now plus a setTimeout that resolves early when signal aborts (a second SIGTERM cuts the pre-drain delay short) and is unref'd, so a shutdown sleep never keeps the event loop alive. createFakeClock() from @btravstack/testing is the other implementation.
The contracts a runtime owes
None of these is checkable by the kernel, and each is silent when broken. Write a runtime shows how to keep them.
- Flush the response inside the unit. A unit is closed the instant its
Resultsettles, and an idle registry is the kernel's permission to callServing.stop(). A runtime that resolves the unit and then writes to its client is racing the transport's teardown — measured: an 8 MB body loses withUND_ERR_SOCKET: other side closed. UnitMeta.idmust be unique per unit unless atraceIdis supplied.traceIddefaults toid, so passing a category (a route template) as the id gives every request the same trace id. A broker message id or job id is already unique; a route template is akind.RuntimeHost.ctxis the application context, and a fork's own scope is not synchronous withhost.run. A port aforkmodule provides reaches the runtime only through theContextforkhands back, and the work runs after anawaitonce that module's own provider is async — a runtime subscribing to an event from inside it must check whether it already fired (@btravstack/http-serverchecksresponse.closedfor exactly this).- A runtime that can stop on its own must say so through
stopped, and must withdraw after a stop the kernel asked for. The field is optional because a listening HTTP server has no third state; a worker whose poll loop can die does, and without it the process outlives the transport with/readyzanswering200. The obligation is the withdrawal — a channel that settles on the ordinary path races every clean shutdown.
A minimal runtime
The smallest runtime that keeps them, from packages/core's compiled README samples — a timer, so nothing external is pulled in. It omits stopped, which is the right answer for a runtime that cannot stop on its own:
import { RuntimePort, type Runtime, type Serving } from "@btravstack/core";
import { Module, Port, Provider } from "@btravstack/di";
import { Ok, OkAsync } from "unthrown";
class Greeter extends Port("Greeter")<{
readonly greet: (name: string) => string;
}> {}
const ticker: Runtime<typeof Greeter> = {
name: "ticker",
resolves: [Greeter],
start: (host) => {
const timer = setInterval(() => {
// Every piece of work goes through `host.run`: that is what makes it
// count towards the drain, and what gives it an `AbortSignal`.
void host
.run({ kind: "tick", id: `${Date.now()}` }, (unit, signal) =>
signal.aborted ? Ok("") : Ok(unit.ctx.get(Greeter).greet("world")),
)
.tapFailure((failure) => {
process.stderr.write(`${JSON.stringify({ tick: failure.tag })}\n`);
});
}, 1_000);
const serving: Serving = {
drain: () => {
clearInterval(timer);
return OkAsync();
},
stop: () => OkAsync(),
};
return OkAsync(serving);
},
};
class Ticker extends RuntimePort<Runtime<typeof Greeter>> {}
const TickerModule = Module("Ticker")({
provides: [Provider(Ticker)({ inject: {}, value: ticker })],
exports: [Ticker],
});A composition root that imports TickerModule must also export Greeter, or start refuses the module against "UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export".
infrastructureExports(imports, provides)
Application starters use this helper to export visible Logger and HealthChecks ports. It follows public module exports, including whole-module re-exports, and includes providers declared at the root. Private providers and business ports remain hidden. Each starter adds its own runtime ports. InfrastructureExports<I, P> describes the same visibility at the type level.
import { infrastructureExports, type InfrastructureExports } from "@btravstack/core";
const ports: InfrastructureExports<readonly [], readonly []> = infrastructureExports([], []);
void ports;