Skip to content

fix(core): clean up repeated shutdown signal listeners - #17625

Merged
kamilmysliwiec merged 3 commits into
nestjs:masterfrom
Raj4478:fix/17623-shutdown-signal-cleanup
Sep 14, 2026
Merged

kamilmysliwiec merged 3 commits into
nestjs:masterfrom
Raj4478:fix/17623-shutdown-signal-cleanup

Conversation

@Raj4478

@Raj4478 Raj4478 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • track the cleanup handler registered for each shutdown signal
  • remove every registered process listener when the application closes
  • share the received-signal guard across registrations so concurrent signals run shutdown hooks once
  • add regression coverage for repeated and separate calls to enableShutdownHooks()

Testing

  • focused shutdown regression tests (3 passed)
  • existing process exit/kill shutdown tests (2 passed)
  • tsc -b packages/core --pretty false
  • oxlint packages/core/nest-application-context.ts packages/core/test/nest-application-context.spec.ts
  • Prettier check and git diff --check
  • public-API verification on Windows:
    • registering SIGTERM twice: listener count 0 -> 1 -> 0
    • registering SIGTERM and SIGINT separately: both 0 -> 1 -> 0

Closes #17623

@kamilmysliwiec

Copy link
Copy Markdown
Member

a single call like enableShutdownHooks(['sigterm', 'SIGTERM']) (the Set-dedup runs before uppercase/trim normalization) registers two listeners but remembers only one, leaking a listener that the old activeShutdownSignals-based removal loop used to clean up

@kamilmysliwiec

Copy link
Copy Markdown
Member

and also Opus feedback: the promoted receivedSignal flag is never reset anywhere so it latches permanently once a cleanup fires, and activeShutdownSignals is now a redundant, never-cleared duplicate of the Map's keys (dropping it in favor of shutdownCleanupRefs.has() would fix the silent no-op when re-enabling hooks after close()). Also worth noting: the new "repeated registration" test passes on pre-PR code, so it doesn't actually guard the fix. The remaining findings are test-code cleanups.

Track each registered cleanup handler and share shutdown state across
registrations so close removes every process listener and concurrent signals run
hooks once.

Closes nestjs#17623
@Raj4478
Raj4478 force-pushed the fix/17623-shutdown-signal-cleanup branch from 706fd6f to 2b44718 Compare August 31, 2026 14:11
@Raj4478

Raj4478 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback. I updated the PR to address each point:

  • normalize and trim signals before deduplicating them
  • use shutdownCleanupRefs as the single source of truth instead of activeShutdownSignals
  • reset the shared received-signal guard when starting a fresh registration cycle, allowing hooks to be re-enabled after close() or a completed signal cleanup
  • replace the ineffective repeated-registration test with regressions for normalized aliases, re-enabling after close, and re-enabling after signal cleanup
  • retain coverage ensuring separately registered concurrent signals execute shutdown only once

I also rebased onto the latest master. The seven relevant shutdown tests, core TypeScript build, Prettier, oxlint, and diff checks pass locally. The updated commit is 2b447180a5b14ed3af00e5c0dcb27b157ab60197.

@micalevisk micalevisk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up, the source changes look correct to me and all three points from the earlier comments are addressed. I ran the unit spec (31 passing), the integration/hooks e2e after a full build (5 passing), lint/prettier, and a handful of targeted source mutations against the new tests.

One thing is still missing before this can go in: there is no regression test for the headline case of #17623, i.e. the same signal registered by two separate enableShutdownHooks() calls. The "normalize" test covers aliases within one call and "separate calls" covers different signals, but if I delete the .filter(signal => !this.shutdownCleanupRefs.has(signal)) line the whole spec still passes, even though that mutant leaks the first listener on close() (the second call overwrites the Map entry and registers a second listener). See the inline suggestion; I confirmed that test fails on master, fails on the mutant, and passes on this branch.

A couple of smaller test-quality points inline (two tests that can pass vacuously, and the repeated finally cleanup). No behaviour regressions found: re-enabling after close(), all-listener removal on the signal path, and running hooks once across registrations all match what the issue asks for and are not breaking.

Out of scope for this PR, but worth a follow-up issue: close() does not check receivedSignal, so calling it while a signal-triggered shutdown is in flight still runs the hook sequence twice (the adjacent behaviour from #14998), and options passed on a later call for an already-registered signal are silently ignored.

Add a regression for the same signal registered by separate calls and
assert listener counts before and after cleanup. Share baseline cleanup,
use the handler map consistently, and document registration behavior.
Reset the shutdown guard alongside listener cleanup.
@Raj4478

Raj4478 commented Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, @micalevisk. Addressed the feedback in commit "a454e680562cfe43f4b96bfcb9171cf48d0eeb30":

  • Added the missing regression for registering the same signal through two separate calls. Confirmed it fails against the pre-PR source and when the duplicate-signal filter is removed, and passes with the fix.
  • Added listener-count assertions before "close()" in the separate-signals and re-enable-after-close tests, plus baseline checks after signal-triggered cleanup.
  • Extracted "removeListenersNotIn()" and consistently retrieved cleanup handlers from "shutdownCleanupRefs".
  • Documented per-signal idempotency and re-enabling after "close()". Also moved the signal-guard reset into "unsubscribeFromProcessSignals()", as suggested.

Validation on Linux with Node 24.19.0:

  • 2,796 unit tests passed, including all 32 application-context tests.
  • 29 integration tests passed across "integration/hooks" and "integration/graceful-shutdown", including real process signals and in-flight HTTP requests.
  • Full package build, changed-file lint, Prettier, and "git diff --check" passed. Full lint reported one existing warning in the unchanged "packages/websockets/socket-module.ts".

The existing manifest/lockfile mismatch prevented "npm ci", so validation used "npm install --legacy-peer-deps --no-package-lock --no-audit --no-fund". No dependency files changed. "NO_COLOR" was unset because the logger spec expects ANSI colors

Promoting the received-signal guard to instance scope stopped concurrent
signals from tearing the context down twice, but `close()` never took part
in it. A signal delivered while a programmatic `close()` was still awaiting
its hooks re-entered the cleanup handler with the guard clear and ran
`callDestroyHook`, `callBeforeShutdownHook`, `dispose` and
`callShutdownHook` a second time, concurrently.

Replace the boolean with the in-flight shutdown promise itself and share it
between both entry points, so whichever arrives second awaits the first
instead of starting its own teardown. The sequence moves into
`runShutdownSequence`, which `close()` and the signal handler now reach
through `shutdown()` rather than each keeping their own copy of the six
lifecycle calls.

Clearing the promise once the cycle settles also unwedges the failure path:
previously a throw skipped `unsubscribeFromProcessSignals`, leaving the flag
latched and the registry populated, so the reset in `enableShutdownHooks`
could never fire and the context ignored every later signal.

Co-Authored-By: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

enableShutdownHooks() leaves a process signal listener behind when called more than once

3 participants