Guard every async socket handler against an escaping rejection - #5828
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
server/sockets/asyncHandlerGuard.test.js. EveryasyncSocket.IO handler inserver/sockets/must own its rejections — a handler runs outside the Express request lifecycle and nobody holds the promise it returns, so a rejectedawaitbecomes an unhandled rejection and kills the server process (that is howvoice:call:detachcould 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.socket.on—ns.on('connection', …)and thepm2 logschild's.on('data', …)inlogs.jssit in the identical blast radius.server/timerCallbackConventions.test.jsalready 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 toserver/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.jstakes itsblankCommentsfrom there too.awaitis flagged; atrythat 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'stry(a fixed character window reads the next statement).(found after an arrow's=>, reading it as a parameter list, soasync () => (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.voice:call:detach) was fixed in106d53d5d(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 inserver/AGENTS.mdbeside the timer rule, and routed inscripts/ci-test-plan.jsso an edit underserver/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.jstry/catchfromvoice:call:detachmakes the guard fail withserver/sockets/voice.js line 543 'voice:call:detach': await detachHost(socket); restoring it goes green.Closes #5678