Graceful shutdown
Drain in-flight requests and jobs, run teardown hooks, and close DB/queue
connections when your app receives SIGINT/SIGTERM — so a deploy or Ctrl+C
never drops work mid-flight.
Prerequisites
| Name | Type | Description |
|---|---|---|
@netscript/service |
package |
Provides createService().onShutdown().serve() — signal handling and request draining live here. |
@netscript/plugin-workers-core/shutdown |
subpath export |
ShutdownManager for draining worker/scheduler resources; the worker runtime also exposes a runtime.shutdown handle. |
@netscript/queue |
package (if you consume a queue) |
MessageQueue.listen(handler, { signal }) + stop() let an AbortSignal stop consumption gracefully. |
A long-running entrypoint |
main.ts |
A service or worker process you start with deno run / aspire start that needs to stop cleanly. |
Step 1 — Drain a service with .onShutdown()
serve() already drains in-flight HTTP requests. Add .onShutdown(hook) to close
the things the framework does not own — your database client, an external connection,
a flush buffer. Each ShutdownHook receives a ShutdownContext (reason, optional
signal) and runs during the drain, in LIFO order (last registered, first to run).
// services/users/src/main.ts
import { createService } from '@netscript/service';
import { router } from './router.ts';
import { db } from '@database';
const running = await createService(router, { name: 'users', version: '1.0.0' })
.withRPC()
.withHealth()
.onShutdown(async ({ reason, signal }) => {
// reason: 'signal' | 'manual' | 'startup-failure'
audit.record({ event: 'shutdown', reason, signal });
await db.$disconnect();
})
.serve({
port: 3001,
drainTimeoutMs: 10_000, // wait up to 10s for in-flight work
handleSignals: true, // SIGINT/SIGTERM (SIGBREAK on Windows) — the default
});
// In tests or a supervisor, trigger the same drain manually:
await running.stop();
The drain is bounded by drainTimeoutMs (default 30_000). When it elapses the
service stops anyway and the returned ShutdownReport records timedOut: true plus a
per-hook ShutdownHookOutcome. running.stop() runs the identical drain with reason
'manual'; both paths are idempotent, so a double Ctrl+C will not double-run hooks.
| Name | Type | Description |
|---|---|---|
.onShutdown(hook) |
ServiceBuilder method |
Registers an async teardown ShutdownHook. Hooks run LIFO during the drain. |
ShutdownHook |
(context: ShutdownContext) => Promise |
Your teardown callback. Throwing is captured, not fatal — it lands in the report as a failed hook. |
ShutdownContext |
{ reason: ShutdownReason; signal?: Deno.Signal } |
signal is set only when reason is 'signal'. |
ShutdownReason |
'signal' | 'manual' | 'startup-failure' |
An OS signal, a manual stop() call, or a failed startup hook. |
ShutdownReport |
{ reason; timedOut: boolean; hooks: readonly ShutdownHookOutcome[] } |
Returned by stop(); timedOut is true when the drain budget elapsed first. |
Step 2 — Configure the drain budget via serve()
The drain budget and signal behavior are ServeOptions keys. Pass an external
AbortSignal to stop the listener from anywhere — a parent controller, a test
harness, or a higher-level orchestrator — without an OS signal.
| Name | Type | Description |
|---|---|---|
drainTimeoutMs |
number? |
Max time to wait for in-flight requests and shutdown hooks before forcing exit. Defaults to 30_000. |
handleSignals |
boolean? |
Install SIGINT/SIGTERM (or SIGBREAK) handlers. Defaults to true — set false only if a parent process owns signals. |
signal |
AbortSignal? |
External signal that stops the listener (and runs the drain) when aborted. Reason is reported as 'manual'. |
port |
number? |
Preferred listener port; 0 for an ephemeral port. |
// supervisor.ts — stop a service from a parent AbortController
const controller = new AbortController();
const running = await createService(router, { name: 'users' })
.withRPC()
.serve({ port: 3001, signal: controller.signal, drainTimeoutMs: 15_000 });
// Anywhere in the parent: triggers the same bounded drain.
controller.abort();
Step 3 — Drain a worker runtime
The worker runtime carries its own ShutdownManager and exposes it as
runtime.shutdown. The runtime pre-registers its worker (priority 20) and, when
present, its scheduler (priority 30); higher-priority resources stop first. Register
your own long-lived resources — a queue consumer, a connection pool — and call
runtime.shutdown.shutdown(reason) to stop them all under one bounded budget.
// workers/src/main.ts — drain workers + a queue consumer on signal
import { ShutdownManager } from '@netscript/plugin-workers-core/shutdown';
// runtime.shutdown is a RuntimeShutdownManager: { register, shutdown }
runtime.shutdown.register({
id: 'queue-consumer',
priority: 10, // stops after worker (20) and scheduler (30)
stop: async () => {
await queue.stop(); // let in-flight messages ack before exiting
},
});
// Workers do NOT auto-install signal handlers — wire them yourself:
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
Deno.addSignalListener(signal, () => {
void runtime.shutdown.shutdown(signal);
});
}
You can also build a standalone ShutdownManager directly. Its createAbortController()
returns an AbortController that aborts the moment shutdown starts — feed that signal
into queue.listen(handler, { signal }) so consumption stops cleanly, and register()
each resource you want stopped (higher priority stops first).
| Name | Type | Description |
|---|---|---|
register(resource) |
(ShutdownResource) => void |
Adds a resource: { id, priority?, stop(reason?) }. Higher priority stops first; default 0. |
shutdown(reason?, options?) |
=> Promise |
Stops all registered resources concurrently under timeoutMs (default 30_000); idempotent — returns the same report on repeat calls. |
createAbortController() |
=> AbortController |
An AbortController that aborts when shutdown begins. Pass its .signal to queue.listen({ signal }). |
waitForShutdown() |
=> Promise |
Resolves once shutdown has started — await it to gate your own cleanup. |
state |
'running' | 'shutting-down' | 'stopped' |
Current lifecycle state. |
Step 4 — Stop a queue consumer cleanly
A MessageQueue consumer is a long-running listen() loop. Two ways to stop it
without dropping a message mid-process: pass an AbortSignal to listen(), or call
queue.stop() (which lets in-flight messages finish before shutting down). Wiring the
AbortSignal from a ShutdownManager ties consumption directly to your drain.
// workers/src/consumer.ts — stop consuming when shutdown begins
import { ShutdownManager } from '@netscript/plugin-workers-core/shutdown';
const manager = new ShutdownManager({ timeoutMs: 15_000 });
const { signal } = manager.createAbortController();
// listen() returns when the signal aborts; in-flight messages ack/nack first.
await queue.listen(
async (message, ctx) => {
await handle(message);
await ctx.ack();
},
{ signal, concurrency: 5 },
);
// workers/src/consumer.ts — drain via stop()
// stop() halts the listen loop and lets in-flight messages complete.
await queue.stop();