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<Needs, Info>
type Runtime<Needs extends AnyPort = never, Info = never> = {
readonly name: string;
readonly needs: readonly Needs[];
readonly start: (
host: RuntimeHost<Needs>,
) => AsyncResult<Serving<Info>, RuntimeStartFailed>;
};| Member | Semantics |
|---|---|
name | Reported on the serving event. |
needs | 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 needs: [] — what its handlers need 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. |
Needs is parameterised by port classes (AnyPort) but hands out Context<InstanceType<Needs>>, because di parameterises Context<in R> by port instance types. InstanceType<never> is never, so a needs-free runtime gets a context it can read nothing from.
RuntimeHost<Needs>
type RuntimeHost<Needs extends AnyPort> = {
readonly ctx: Context<InstanceType<Needs>>;
readonly run: RunUnit<Needs>;
};ctx is the application context — the module's exports, never a StartOptions.unit module's. run is the kernel's unit registry, closed over that context.
RunUnit<Needs>
type RunUnit<Needs extends AnyPort> = <T, E>(
meta: UnitMeta,
work: (
ctx: Context<InstanceType<Needs>>,
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. With a unit module, ctx is the forked context (Context<X | UnitX>), built before work runs and torn down after it settles.
Serving<Info>
type Serving<Info = never> = {
readonly drain: (signal: AbortSignal) => AsyncResult<void, never>;
readonly stop: () => AsyncResult<void, never>;
readonly info?: Info;
};| 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. |
Both are typed AsyncResult<void, never>; never empties the error channel only, so a drain that throws internally arrives at the kernel as a Defect and is threaded, not dropped.
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 Needs/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 or PROBE_PORT is malformed (its cause then a ConfigInvalid).
Units of work
type UnitMeta = {
readonly kind: string;
readonly id: string;
readonly traceId?: string;
readonly tenantId?: string;
readonly deadline?: number;
};
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 deadline: number | undefined;
readonly signal: AbortSignal;
};
const currentUnit: () => UnitRecord | 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 minted per unit and always unique; 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. |
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.
Three 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 unit work is not synchronous withhost.run. A unit-provided port reaches the runtime only throughrun's work callback, and with aunitmodule the callback runs after anawait— a runtime subscribing to an event from inside it must check whether it already fired (@btravstack/httpchecksresponse.closedfor exactly this).
A minimal runtime
The smallest runtime that keeps all three, from packages/core's compiled README samples — a timer, so nothing external is pulled in:
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",
needs: [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()}` }, (ctx, signal) =>
signal.aborted ? Ok("") : Ok(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)({ value: ticker })],
exports: [Ticker],
});A composition root that imports TickerModule must also export Greeter, or start fails with UNSATISFIED RUNTIME NEEDS.