Skip to content

Guard every async socket handler against an escaping rejection - #5828

Merged
atomantic merged 2 commits into
mainfrom
claim/issue-5678
Sep 2, 2026
Merged

Guard every async socket handler against an escaping rejection#5828
atomantic merged 2 commits into
mainfrom
claim/issue-5678

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

  • New guard: server/sockets/asyncHandlerGuard.test.js. Every async Socket.IO handler in server/sockets/ must own its rejections — a handler runs outside the Express request lifecycle and nobody holds the promise it returns, so a rejected await becomes an unhandled rejection and kills the server process (that is how voice:call:detach could have gone down, voice:call:detach is the one async socket handler in voice.js with no try/catch, so a failed detach takes the server down #5661). The guard scans the directory, and a failure names the file, the line and the event string.
  • Scope is every emitter in the directory, not just socket.onns.on('connection', …) and the pm2 logs child's .on('data', …) in logs.js sit in the identical blast radius.
  • One shared implementation instead of a second copy. server/timerCallbackConventions.test.js already had a hardened lexer, bracket matcher, callback parser and await checker for the same rule on timer callbacks. Rather than duplicating it, that machinery moved to server/lib/sourceScan.js (barrel + README row) and both guards now consume it, so the timer rule and the socket rule cannot drift on what "owns its rejection" means. childProcess.guards.test.js takes its blankComments from there too.
  • Three bypass probes, because a source scan that silently matches nothing passes green forever: a bare await is flagged; a try that exists only in a comment does not satisfy the guard; and each handler body is captured by a bracket-balanced walk, so an unguarded handler cannot pass on its neighbour's try (a fixed character window reads the next statement).
  • One review finding fixed in the same diff: the callback parser skipped a ( found after an arrow's =>, reading it as a parameter list, so async () => (await f()) had its whole body jumped and every await in it went unseen. That predates the extraction — the timer guard carried the same line — and it exempted a real spelling from both guards. Both recognizers now cover the shape.
  • No source fix needed. The one offender the issue named (voice:call:detach) was fixed in 106d53d5d (voice:call:detach is the one async socket handler in voice.js with no try/catch, so a failed detach takes the server down #5661) before this landed; every one of the 19 async handlers is green today. Documented in server/AGENTS.md beside the timer rule, and routed in scripts/ci-test-plan.js so an edit under server/sockets/ selects the guard (it readdir-scans rather than imports, so no import edge reaches it).

Test plan

  • cd server && npm test — full suite green.
  • npx vitest run sockets/asyncHandlerGuard.test.js timerCallbackConventions.test.js lib/index.test.js lib/childProcess.guards.test.js ../scripts/ci-test-plan.test.js ../scripts/repo-scan-guards.test.js
  • Mutation-verified: stripping the try/catch from voice:call:detach makes the guard fail with server/sockets/voice.js line 543 'voice:call:detach': await detachHost(socket); restoring it goes green.
  • The timer guard's 13 existing tests pass unchanged against the extracted module, which is the regression check on the extraction.

Closes #5678

…5678)

A Socket.IO handler runs outside the Express request lifecycle and nobody
holds the promise it returns, so a rejected `await` surfaces as an unhandled
rejection — fatal on Node >= 15. That is a server-process death, not a 500,
and it takes every agent run, PTY session and media job with it.
`voice:call:detach` shipped exactly that shape (#5661); the other 18 handlers
held by review alone, with nothing to catch the next one.

`server/sockets/asyncHandlerGuard.test.js` scans the directory for it and
names the file, line and event string of any offender. Scope is every emitter
in the tree, not just `socket.on` — `ns.on('connection', …)` and the pm2-logs
child's `.on('data', …)` sit in the same blast radius.

`timerCallbackConventions.test.js` already carried a hardened lexer, bracket
matcher, callback parser and await checker for the identical rule on timer
callbacks, so rather than growing a second copy that could drift, that
machinery moves to `server/lib/sourceScan.js` and both guards consume it.
`childProcess.guards.test.js` takes its `blankComments` from there too.

Three bypass probes keep the scan from degrading into a silent no-op: a bare
await is flagged, a `try` that exists only in a comment does not satisfy it,
and each handler body is captured by a bracket-balanced walk so an unguarded
handler cannot pass on its neighbour's `try`.

No handler needed fixing — 106d53d had already guarded voice:call:detach.
…row body

`parseCallbackAt` skipped a `(` found after the arrow's `=>`, treating it as a
parameter list — so `async () => (await f())` had its whole body jumped and
every await inside it went unseen. That hole predates the extraction (the timer
guard carried the same line), and it silently exempted a real spelling from
both guards.

Only the `function` spelling needs a parameter list consumed at that point, so
the skip moves into that branch. Both recognizers gain a case for the shape.

Also widen the socket registration match to `.once` and to whitespace before
the paren: a one-shot listener hands its promise to nobody exactly the way `.on`
does, and leaving it out was a hole rather than a narrower rule.
@atomantic
atomantic merged commit 5a12e5e into main Sep 2, 2026
9 of 14 checks passed
@atomantic
atomantic deleted the claim/issue-5678 branch September 2, 2026 06:57
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.

Give async Socket.IO handlers the same source-scan guard that child-process spawns already have

1 participant