ADR 0115 — A managed server resource owns when the server exists
Context
managedServerResource accepted either a handle or a thunk:
readonly server: ManagedServerHandle<TRuntime> | (() => ManagedServerHandle<TRuntime>)
Its start() was empty, and the thunk was called from ensureShutdown — that
is, on stopAdmission / drain / close / force. Adapting an
already-running server was the documented intent, and for that the empty start
was right.
But a thunk reads as the opposite. server: () => createServer(config) says
“make the server when it is time”, and that is exactly what an application
needs, because it is the only way to express bind the port after the database
is up. Written that way, the graph did this:
await app.start()
// resolves. health: 'healthy'. ready: true. every resource ready.
// createServer was never called. nothing is listening.
No error, no warning, no degraded. The failure surfaced outside the process —
as a request that never arrived, or a health check if one existed.
That is the worst available shape, and it is the one the kernel exists to prevent. The whole argument for the application kernel is that ready stops being a tautology: the snapshot means the required graph is genuinely up. Here the snapshot became the tautology.
Decision
start() resolves the server; the thunk is called there, and the handle is
published to dependants (→ ADR 0114). The thunk may be asynchronous, because
“bind the port after the database is up” often needs to await something.
Three cases, and the shutdown path distinguishes them:
startran and produced a handle — the ordinary graph. Shut that handle down.startran and produced nothing — a thunk that threw. There is no server. Calling the thunk again during the rollback would raise its failure a second time and turn one honest startup error into “startup and rollback failed”.startnever ran at all — the resource is spread over someone else’sstart, which is the workaround the broken version forced on consumers. The thunk is still the only way to reach their server, so it is still called.
The third case is why this is not simply “call the thunk in start”. A consumer who worked around the defect wrote:
let handle: ManagedServerHandle<T> | null = null
const shutdown = managedServerResource({ id: 'http', server: () => handle! })
const http = defineManagedResource({
...shutdown,
dependsOn: ['database', 'socket-io'],
start: () => { handle = createServer(config) },
})
Their start overrides the adapter’s, so the adapter’s never runs. Removing the
lazy path would leave their server up forever — punishing precisely the people
who worked around the bug being fixed.
The shutdown call also stays synchronous whenever the handle is already in
hand, which is every case but a pending async thunk. stopAdmission closes the
admission gate by making that call; deferring it by a microtask would admit
requests after the application decided to stop accepting them.
Consequences
- A graph may delegate server creation to the resource that owns the server, and the port is bound after the dependencies are ready — expressed in the graph rather than by spreading one resource over another.
- A thunk that throws fails the startup with its own error, and the rollback closes what was actually opened.
- An already-created handle behaves exactly as before:
startadopts it, and nothing about the shutdown changes. - The migration recipes gain the managed HTTP server, which is the main resource of any web backend and was the one case they did not cover.