Skip to main content
Alpha

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

What you need
NameTypeDescription
@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.

Shutdown contract (@netscript/service)
NameTypeDescription
.onShutdown(hook) ServiceBuilder method Registers an async teardown ShutdownHook. Hooks run LIFO during the drain.
ShutdownHook (context: ShutdownContext) => Promise | void 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.

ServeOptions — shutdown-relevant keys
NameTypeDescription
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).

ShutdownManager (@netscript/plugin-workers-core/shutdown)
NameTypeDescription
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();

In-production pitfalls

See also

service

Background jobs