Skip to content

fix(plugins): make SIGKILL escalation idempotent per child - #13092

Merged
diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
huuhungn:fix/plugin-sigkill-listener-leak-12819
Sep 11, 2026
Merged

diegosouzapw merged 1 commit into
diegosouzapw:release/v3.8.51from
huuhungn:fix/plugin-sigkill-listener-leak-12819

Conversation

@anhtahaylove

Copy link
Copy Markdown
Contributor

loadPlugin() attached a new child.once("exit", () => clearTimeout(killTimer)) on every SIGTERM→SIGKILL escalation. once only detaches when exit actually fires, so a plugin that traps SIGTERM keeps serving calls and accumulates one listener plus one killTimer closure per hook timeout. Node prints MaxListenersExceededWarning once eleven pile up. Healthy plugins never reach this path.

The change

Escalation is now guarded by a WeakSet keyed on the child, so a child already being killed does not re-arm. That is also all that is useful: SIGKILL cannot be ignored, so a second timer would only re-signal a corpse. Both the timer and the listener are released on every path — including the one where the child never exits, which once alone does not cover.

The shared helper also replaces two copies of the same block (hook-timeout path and cleanup()).

Note on the first attempt

Self-detaching the listener inside onExit is not sufficient on its own, and the test caught it. The grace period is 3s, far longer than the interval between hook timeouts, so listeners still accumulated faster than they were released:

AssertionError: hook timeouts must not accumulate exit listeners (#12819):
  Possible EventEmitter memory leak detected. 11 exit listeners added to [ChildProcess].

Idempotence per child is what actually fixes it.

Test

tests/unit/plugins-sigkill-listener-leak-12819.test.ts loads a real plugin that traps SIGTERM and never answers a hook, then drives 12 timeouts. It observes the leak the way a user does — by listening for Node's own MaxListenersExceededWarning — because the child handle is private to the loader; asserting on a handle the test cannot reach would have silently passed either way.

Verification

plugins-sigkill-listener-leak-12819 + plugins-loader + plugins-loader-ipc   14 passed
npx tsc -p tsconfig.typecheck-core.json --noEmit    clean
npx eslint <changed files>                           clean

Fixes #12819

loadPlugin() attached a fresh `child.once("exit", () => clearTimeout(killTimer))`
on every SIGTERM->SIGKILL escalation. `once` only detaches when exit actually
fires, so a plugin that traps SIGTERM keeps serving calls and accumulates one
listener plus one killTimer closure per hook timeout. Node prints
MaxListenersExceededWarning once eleven pile up.

Escalation is now guarded by a WeakSet keyed on the child, so a child already
being killed does not re-arm. That is also all that is useful: SIGKILL cannot be
ignored, so a second timer would only re-signal a corpse. Both the timer and the
listener are released on every path, including the one where the child never
exits.

The shared helper replaces two copies of the same block, in the hook-timeout
path and in cleanup().

Fixes diegosouzapw#12819
@anhtahaylove

Copy link
Copy Markdown
Contributor Author

Note on the checks here: the workflows are sitting in action_required because this is my first PR to this repo, so GitHub holds them for maintainer approval. The three green checks shown (Mergify, semgrep) are the ones that run without it — the test and typecheck jobs have not run yet. Approving the workflow run will give you the real signal.

Locally on Windows 11 / Node 24.19.0: tsc -p tsconfig.typecheck-core.json --noEmit and eslint are clean on the changed files, and the touched suites pass. The 5 failures in the full tests/unit/compression/** run are pre-existing RTK cases that reproduce on a clean release/v3.8.51 checkout with my changes stashed.

@diegosouzapw
diegosouzapw merged commit b1733d3 into diegosouzapw:release/v3.8.51 Sep 11, 2026
3 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…apw#13092)

`once` only detaches when exit actually fires, so a plugin trapping SIGTERM accumulated one listener and one timer per hook timeout. Keying idempotence on the child via a `WeakSet` is right — a second SIGKILL timer would only re-signal a corpse.

---

Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the other 13 PRs of this batch — zero merge conflicts between them.

- `typecheck:core` clean
- complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline
- 71 focused assertions green across the 13 test files this batch adds or touches

⚠️ base-red inherited: diegosouzapw#12732 — `Docs Gates (fast-path)`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and `open-sse/utils/stream.ts` at 3115 > frozen 3098). None of them touch this diff.

Thanks @anhtahaylove — the root-cause write-up, the measured before/after numbers and the red-before-green proof on every one of these made the batch reviewable as a unit.
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.

fix(backend): Plugin loader leaks an exit listener per hook timeout (MaxListenersExceededWarning)

2 participants