ADR 0074 — Server-owned managed shutdown
Context
Stitchkit created HTTP and Socket.IO transports but returned unrelated runtime
handles. Applications guessed an order between server.stop()/close() and
socket.io.close(). On Bun, bun-engine starts a WebSocket close handshake but
has no completion Promise; graceful Bun.Server.stop(false) can therefore wait
past the supervisor deadline. On Node, Socket.IO itself closes its attached
http.Server, while srvx had its own signal/close lifecycle. Two close owners
could race, and upgraded sockets survive closeAllConnections().
A Fetch wrapper alone is not a complete lifecycle boundary. Bun native routes
run before fetch; Node Engine.IO polling and upgrades are attached directly to
the HTTP server. Handler completion also does not prove that a streaming response
has physically finished.
Decision
createServer() and serveNode() return one
ManagedServerHandle<TRuntime>: { url, port, runtime, status, shutdown() }.
The first shutdown() closes admission and fixes one monotonic grace budget,
one forced-completion timeout and an optional caller AbortSignal. Later calls
return the same Promise object and cannot replace its options. The grace budget
is shared by every non-destructive phase; physical confirmation after the force
boundary has its own bounded timeout because it necessarily starts after the
grace budget has been consumed.
The state chain is running → draining-http → closing-realtime → stopping-runtime → clean | forced:
- ordinary HTTP admitted after the boundary receives
503,Retry-AfterandConnection: closeoutside applicationwrapFetch; - accepted application work drains;
- Socket.IO transport stays outside application counters, stops new handshakes through its composed policy, then closes namespaces, adapters and clients;
- Bun starts a normal close for every tracked WebSocket and waits for the
server-side
closecallback; only the forced path callsterminate(). Node uses Socket.IO as the sole graceful close owner when attached; - the runtime stops. At deadline or external abort, Bun forces runtime stop; Node destroys every tracked TCP socket, including upgraded WebSockets. Both adapters must confirm physical closure before the forced result; timeout or adapter failure rejects rather than manufacturing zero pending counters.
An exception from realtime or graceful runtime close does not bypass teardown:
the lifecycle runs the same forced adapter, marks the live state forced, then
rejects with the original phase error.
acceptedRequests and completedRequests describe application admission.
Transport pending counts describe physical state. A forced result preserves
pending*AtForce and aborted counters while final pending counts describe the
post-close state.
Socket.IO is passed as the full socket handle. Bun mounts its route and default
WebSocket handler automatically. A custom composed handler remains explicit and
must include socketIoLane; the managed server observes every lane’s open/close
and owns shutdown. Node keeps a Bun-free structural socket lifecycle.
Bun native routes are removed from managed config because they bypass the
admission boundary; use rawRoutes. srvx is imported from srvx/node and runs
with gracefulShutdown: false, so core never registers process signals. Apps
wire signals explicitly and close non-server resources after server drain.
Alternatives rejected
- A standalone
shutdown(server, socket)helper: preserves split ownership and lets callsites keep the wrong order. stop(true)immediately on every signal: aborts accepted HTTP work and cannot report whether the shutdown was clean.- Parallel
io.close()and srvx close on Node: both own the same listener. - Gating only Fetch: misses Bun native routes and Engine.IO’s attached lanes.
- Framework-owned Prisma/MCP/queue orchestration: those resources are outside the HTTP/realtime transport boundary.
Consequences
- The server return shapes and Bun native
routesconfig are breaking changes. - Bun and Node share semantics and result schemas but retain typed runtime escape hatches.
- Raw Bun WebSocket lanes remain the application’s protocol, but their physical
sockets participate in the managed lifecycle. Bun 1.3.14 reports server-side
physical completion immediately after
ServerWebSocket.close(), even when a raw peer never reads or acknowledges the close frame; that proven runtime boundary is clean, while any socket still tracked at the shared deadline is terminated and retained in the forced snapshot. - Signal policy stays explicit in the application; the framework owns no global process listeners.