Skip to content

fix(gateway): drain transports on SIGTERM, exit on unhandled faults - #30

Open
menor wants to merge 2 commits into
mainfrom
fix/graceful-shutdown
Open

fix(gateway): drain transports on SIGTERM, exit on unhandled faults#30
menor wants to merge 2 commits into
mainfrom
fix/graceful-shutdown

Conversation

@menor

@menor menor commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Two process-lifecycle bugs in gateway.ts. Both are in the same 18-line block, so they ship together as two commits — either can be dropped.

1. SIGTERM never closed transport sessions

Upsun sends SIGTERM on deploy and on stop. Handler coverage before this PR:

Signal index.ts handler (flush telemetry) gateway.ts handler (close transports)
SIGTERM runs never runs
SIGINT (local Ctrl-C) runs runs — both, each calling process.exit(0)

So production never closed active SSE or Streamable HTTP sessions. Locally, the two SIGINT handlers raced: whichever finished its async work first exited the process and truncated the other.

GatewayServer.shutdown() now drains both transports and returns. The signal handlers in index.ts own the ordering and the exit, so SIGTERM and SIGINT run identical steps. Each step is guarded on its own — shutdownTelemetry() rethrows on failure, which previously escaped the handler and left the process hanging until SIGKILL.

Measured on the built server (node build/index.js, then kill -TERM):

main this branch
log output Shutting down gracefully... Shutting down gracefully...
Closing active transport sessions...
Transport sessions closed
Shutdown complete
exit code 0 0

2. Unhandled faults left the server running

Registering a listener for uncaughtException or unhandledRejection overrides Node's default crash. The existing handlers only logged. For rejections that is a behavior change Node made in v15 — unhandled rejections terminate the process unless something suppresses them, and this suppressed them.

Both handlers now process.exit(1). Upsun's supervisor restarts the process (docs: "restarted if ever terminated") and .upsun/config.yaml runs node build/index.js directly under it, so a restart is a shorter outage than an unbounded broken state. Neither handler drains transports first: the state that made them fire is the state not to run more async work on.

Measured by booting the built GatewayServer and triggering each fault:

Fault main this branch
unhandledRejection still serving after 2s exit 1
uncaughtException still serving after 2s exit 1

Tests

test/core/gateway.test.ts gains 3 cases (487 -> 489, all green):

  • shutdown() closes sessions on both transports
  • listen() registers no signal handlers of its own — this fails on main
  • each crash handler exits 1 (it.each)

The old SIGINT block had no test. afterEach detaches handlers listen() added, so an exit(1) handler cannot leak into a later test and kill the Jest worker.

Checks

  • npm run build clean
  • npm test 489 passed, 26 suites
  • npm run lint clean
  • npx prettier --check clean on all three changed files

No tool output changes, so no benchmark run.

menor added 2 commits July 29, 2026 07:19
Upsun sends SIGTERM on deploy and on stop. Only `index.ts` handled it, and
that handler flushed telemetry without touching the transports, so active
SSE and Streamable HTTP sessions were never closed in production.

SIGINT had the opposite problem: `index.ts` and `gateway.ts` both handled it,
each with its own async work and its own `process.exit(0)`. Whichever finished
first killed the other's cleanup.

Consolidate into one path. `GatewayServer.shutdown()` drains both transports
and returns; the signal handlers in `index.ts` own the ordering and the exit.
Each step is guarded so a failure cannot skip the next step or the exit —
`shutdownTelemetry()` rethrows, which previously escaped the handler and left
the process hanging until SIGKILL.

Verified on the built server: SIGTERM logs "Closing active transport sessions"
-> "Transport sessions closed" -> "Shutdown complete", exit code 0.
Registering a listener for either event overrides Node's default crash. The
existing handlers only logged, so the server kept serving requests after a
fault it could not reason about — and since Node 15 that includes unhandled
rejections, which would otherwise terminate the process on their own.

Exit 1 instead. Upsun's supervisor restarts the process ("restarted if ever
terminated"), and .upsun/config.yaml runs `node build/index.js` directly under
it, so a restart is a shorter outage than an unbounded broken state. Neither
handler drains transports first: the state that made them fire is exactly the
state not to run more async work on.

Verified against the built server: an unhandled rejection and an uncaught
exception each exit 1. On main both leave the process running.
@github-actions

Copy link
Copy Markdown

Coverage after merging fix/graceful-shutdown into main will be

93.42%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   mcpUpsun.ts96%75%100%97.44%171–172
src/command
   activity.ts100%100%100%100%
   backup.ts100%100%100%100%
   certificate.ts100%100%100%100%
   domain.ts100%100%100%100%
   environment.ts98.70%94.44%100%100%339
   organization.ts100%100%100%100%
   project.ts91.49%66.67%100%100%139, 173, 71, 78
   route.ts100%100%100%100%
   ssh.ts100%100%100%100%
src/core
   authentication.ts85.71%77.08%95%88.89%113–114, 133, 218–219, 221, 296–298, 321, 332–333, 353–354, 354, 354, 354, 354–355
   config.ts91.47%91.04%100%91.04%121, 133, 140, 140, 140, 140, 142, 145, 149, 156, 156, 160, 162, 316, 353, 36, 51, 62
   gateway.ts93.51%100%91.67%93.33%261–264
   helper.ts89.84%81.48%100%91.43%208–209, 241–242, 337, 341–342, 349, 353, 422–424, 461–462, 512–513, 517, 544–545
   lean.ts90.70%88.89%100%90.91%85, 88, 91, 95
   logger.ts98.61%96.15%100%100%83
   requestContext.ts100%100%100%100%
   telemetry.ts92.09%88.57%100%92.55%163, 200–201, 250–251, 299–300, 350–351, 372–373
   types.ts100%100%100%100%
src/core/transport
   http.ts83.56%80%81.25%85.26%102, 102, 102–103, 158–159, 174–175, 183–184, 189–190, 204–205, 208, 51–52, 55, 92, 95, 97
   sse.ts82.29%63.64%85.71%88.06%100, 102–103, 114, 135, 54–55, 58, 81–82, 85, 99, 99, 99, 99, 99
src/task
   config.ts100%100%100%100%

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