Graceful Shutdown
Production systems must shut down cleanly under three conditions:
The orchestrator sends
SIGTERM(deploy, scale-down, restart).Operator pressed Ctrl-C (
SIGINT).The process supervisor signals reload (
SIGHUP).
Titan handles all three with a phased shutdown coordinator that
guarantees a hard exit within gracefulShutdownTimeout, regardless
of in-flight work.
Hook-driven shutdown
For most providers, you do not register shutdown tasks directly —
you implement OnStop and/or OnDestroy:
@Service({ name: 'Orders' })
export class OrdersService implements OnStop, OnDestroy {
async onStop() { await this.queue.drain(); }
async onDestroy() { await this.metrics.flush(); }
}The shutdown coordinator runs onStop in reverse dependency order,
then onDestroy in the same order, then issues a final hard-exit
guarantee.
Triggering shutdown
| Source | Effect |
await app.stop() | Programmatic; reason = ShutdownReason.Manual |
SIGTERM | Standard orchestrator stop; reason = SIGTERM |
SIGINT | Ctrl-C; reason = SIGINT |
SIGHUP | Reload; reason = SIGHUP |
| Uncaught exception | reason = UncaughtException |
| Unhandled rejection | reason = UnhandledRejection |
Reasons (from ShutdownReason enum): Manual, SIGTERM, SIGINT,
SIGHUP, Signal, UncaughtException, UnhandledRejection,
Timeout, Error, Reload, Upgrade, Maintenance.
app.stop() options
IShutdownOptions is { timeout?, force?, graceful?, signal? }:
await app.stop({
timeout: 10_000, // override gracefulShutdownTimeout for this call
force: false, // true → swallow hook/task/module errors, push to Stopped
graceful: true, // false → a module-stop error aborts (unless force)
});A direct app.stop() always reports ShutdownReason.Manual to
shutdown-task handlers and the ShutdownComplete event. The reason
variants (SIGTERM, Maintenance, …) come from the signal/programmatic
path that calls the internal shutdown(reason) entry, not from a
reason field on stop().
Draining Netron work
Before provider teardown, the inbound gate refuses new calls, property reads/writes and Netron tasks with SHUTTING_DOWN. Already admitted work stays counted until its result or error has been written to the transport. The drain has a deadline; it cannot promise completion of an operation that outlives shutdown.
If a connection disappears first, pending callers receive TransportLostError with the packet ID instead of waiting for the request timeout. A lost response does not reveal whether a mutation committed. Netron does not replay it automatically. Reconcile by operation ID or state, and retry only under an application-defined idempotency contract.
Stopping Netron also cancels unfinished handshakes and reconnect timers. Long-lived streams and event subscriptions belong to their original connection and must be reopened explicitly; use a durable journal or outbox when messages must survive that connection.
Custom shutdown tasks
For tasks that don't belong to any single provider, register them directly with the application:
import { ShutdownPriority } from '@omnitron-dev/titan';
app.registerShutdownTask({
id: 'audit.flush',
name: 'Flush audit log',
priority: ShutdownPriority.High,
timeout: 5_000,
handler: async () => {
await auditClient.flush();
},
});
// Later, if you no longer want this task:
app.unregisterShutdownTask('audit.flush');ShutdownPriority is a numeric enum (lower = runs first):
First (0), VeryHigh (10), High (20), AboveNormal (30),
Normal (50), BelowNormal (70), Low (80), VeryLow (90),
Last (100).
The hard-exit guarantee
The coordinator runs a watchdog timer for the whole shutdown:
gracefulShutdownTimeout: 30_000 // defaultIf the timer fires before shutdown completes, the process exits with a non-zero code. A misbehaving handler cannot hold the process alive forever.
Why this matters. A single
await db.disconnect()that hangs because the connection is half-closed can deadlock the entire process. In production this means the orchestrator's restart loop stalls; the deploy hangs; on-call gets paged. The hard exit is strictly better: a forced exit is recoverable, an indefinite hang is not.
Disabling auto-binding
If you embed Titan inside a larger Node.js process (test harness,
another framework, an Electron renderer), set
disableGracefulShutdown: true. Titan will not bind signal
handlers; you call app.stop() yourself.
const app = await Application.create({
modules: [AppModule],
disableGracefulShutdown: true,
});Observability
The shutdown coordinator emits these events through the Application event bus:
| Event | Payload |
ApplicationEvent.ShutdownStart | { reason, details } |
ApplicationEvent.ShutdownTaskComplete | { task } |
ApplicationEvent.ShutdownTaskError | { task, error } |
ApplicationEvent.ShutdownComplete | { reason, success } |
ApplicationEvent.ShutdownError | { reason, error } |
Per-phase timing flows through ApplicationEvent.LifecyclePhaseEvent
(a LifecyclePhaseEvent with phase, kind, and timing fields), which
is where duration histograms belong. Subscribe in production to record
shutdown outcomes:
app.on(ApplicationEvent.ShutdownComplete, ({ reason, success }) => {
metrics.counter('app.shutdown', { reason, success: String(success) }).inc();
});Anti-patterns
Long-running work outside
onStop/onDestroy. Anything that needs to clean up at shutdown should be hooked in via the lifecycle interfaces, not started withsetTimeout.Catching errors in shutdown tasks and continuing. A failed task should fail (and let the coordinator move on or escalate), not silently swallow. The coordinator already isolates failures.
Spawning new async work in shutdown. The framework cannot wait for promises you do not return. If you
setTimeoutsomething during shutdown, it will be killed on hard exit.Long blocking sync code. The coordinator can only timeout async work. A 30 s synchronous CPU loop will not be interrupted.