Skip to content

fix(api): standalone server owns SIGTERM so graceful-shutdown cleanup runs - #15331

Open
QuangBlue wants to merge 1 commit into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/standalone-sigterm-cleanup
Open

QuangBlue wants to merge 1 commit into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/standalone-sigterm-cleanup

Conversation

@QuangBlue

Copy link
Copy Markdown
Contributor

⚠️ base-red inherited: #15306

Problem

On the standalone entry point (scripts/dev/standalone-server-ws.mjs, shipped as server-ws.mjs and started by Docker via dev/run-standalone.mjs, by omniroute serve and by Electron), a SIGTERM/SIGINT (docker stop, systemctl stop, Ctrl+C) exits with code 143/130 before OmniRoute's graceful-shutdown cleanup finishes. The spend batch flush, call-log flush and DB checkpoint in src/lib/gracefulShutdown.ts start but do not complete.

Root cause

Next's standalone start-server.js installs its own SIGINT/SIGTERM handlers in its listening handler, unless NEXT_MANUAL_SIG_HANDLE is set. Those handlers call server.close() and then process.exit(143) as soon as no connection is left. That happens long before the async cleanup that initGracefulShutdown() registered on the same signals is done.

scripts/dev/run-next.mjs already avoids this by owning shutdown (__omnirouteCustomServerOwnsShutdown, #12074). The standalone wrapper did not.

Fix

standalone-server-ws.mjs now owns process exit, using the same model as run-next.mjs:

  • It sets NEXT_MANUAL_SIG_HANDLE=1 before ./server.js loads, but only when the variable is empty or unset. An empty value is falsy for Next. The wrapper clears the variable in setImmediate after the first listening event, so spawned child services (some are Next apps) do not inherit it. Next reads it synchronously in its listening handler, and a test guards that.
  • It sets __omnirouteCustomServerOwnsShutdown, so initGracefulShutdown() registers its cleanup as __omnirouteRequestShutdown instead of adding competing signal listeners.
  • It handles SIGINT/SIGTERM/SIGHUP itself (SIGHUP for fix(startup): Windows restart fails with "Database closed" / SQLite driver detection (500) #8045): close the HTTP servers, await __omnirouteRequestShutdown, then exit 0 one macrotask later (fix(backend): Windows: libuv abort when process.exit() follows a sql.js statement #13306).
    • An unref'd force-exit timer bounds the whole sequence at SHUTDOWN_TIMEOUT_MS + 5 s.
    • Duplicate signals are ignored, because the launcher forwards SIGTERM on top of the terminal's SIGINT.

Tests

  • New: tests/unit/standalone-server-ws-shutdown-owner.test.ts. It runs the real wrapper with its shipped siblings next to a fake server.js whose signal handling mirrors Next's start-server.js.
    • On the base, 5 of 7 tests fail. A plain SIGTERM logs cleanup-start and then exits 143 without reaching cleanup-done.
    • With the fix, 7 of 7 pass. The tests cover:
      • cleanup completes, exit 0;
      • the env var does not leak to children;
      • an empty env var still counts as unset;
      • duplicate signals do not cut cleanup short;
      • a signal before instrumentation exits promptly;
      • a cleanup that hangs is bounded by the force-exit timer;
      • a guard on Next's NEXT_MANUAL_SIG_HANDLE read.
  • Neighbours: run-next-*, standalone*, pack-artifact*, assemble*, live-ws-standalone-wiring, graceful-shutdown*: 56/56 pass.
  • npm run typecheck:core, eslint (with the suppressions file) and prettier are clean.

Notes

  • Side effect: in standalone, Next's own nextServer.close() no longer runs on SIGTERM, because the wrapper has no handle to the NextServer. Pending after() / waitUntil work is therefore not awaited. Nothing in src/ or open-sse/ uses after() / waitUntil today, so there is no effect now. A comment in the wrapper flags this for anyone who adds one later.
  • Exit code on a graceful stop is now 0, where it used to be 143 (SIGTERM) or 130 (SIGINT). Nothing in the repo keys off those codes. Docker restart policies and the serve supervisor ignore the exit code.
  • A second signal during shutdown is ignored, so cleanup is not cut short. The bounded force-exit timer still guarantees exit. run-next.mjs handles a second signal differently: it exits 1 immediately.
  • Launchers keep their own SIGKILL fallbacks (omniroute serve, Electron), so the 35 s default bound does not make them hang.

… runs

The standalone entry point (scripts/dev/standalone-server-ws.mjs, shipped as
server-ws.mjs and started by Docker via dev/run-standalone.mjs, by
`omniroute serve` and by Electron) let Next's start-server install its own
SIGINT/SIGTERM handlers. Those call server.close() and then
process.exit(143) as soon as the HTTP server has no connection left, which
preempts src/lib/gracefulShutdown.ts (spend batch flush, call-log flush,
DB checkpoint). With a fake Next server.js that mirrors start-server's
signal handling, SIGTERM on the base wrapper logs `cleanup-start` and exits
143 without ever reaching `cleanup-done`.

run-next.mjs already avoids this via __omnirouteCustomServerOwnsShutdown
(diegosouzapw#12074); the standalone path now uses the same model:
- set NEXT_MANUAL_SIG_HANDLE=1 before ./server.js loads (only when empty or
  unset), and clear it in setImmediate after the first 'listening' so
  spawned child services do not inherit it;
- set __omnirouteCustomServerOwnsShutdown so initGracefulShutdown registers
  its cleanup instead of competing signal listeners;
- own SIGINT/SIGTERM/SIGHUP (diegosouzapw#8045): close the HTTP servers, await
  __omnirouteRequestShutdown, exit 0, bounded by an unref'd force-exit
  timer of SHUTDOWN_TIMEOUT_MS + 5 s. Duplicate signals are ignored.

Tests: tests/unit/standalone-server-ws-shutdown-owner.test.ts runs the real
wrapper against the fake server.js (5/7 fail on the base, 7/7 pass), plus a
guard that Next still reads NEXT_MANUAL_SIG_HANDLE synchronously in its
'listening' handler.
@QuangBlue
QuangBlue force-pushed the fix/standalone-sigterm-cleanup branch from 6ea393d to a25011b Compare October 2, 2026 05:18

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant