fix(grpc): await the port bind so shutdown cannot orphan a listener - #746
Conversation
startServer() assigned the module-level server before bindAsync completed and returned undefined, so start.js:11 awaited nothing and a signal during the bind window skipped the drain. grpc-js registers a listening server inside its own bind callback, so a shutdown in that window found no http2Servers, resolved immediately having drained nothing, and cleared the module reference. The in-flight bind then completed and registered a listening socket nothing could reach, which kept accepting RPCs for the rest of onSignal while the cache, job queue, worker pool and subscriber were torn down underneath it. Return a promise that resolves in the bind callback and rejects on bind error, assign server only once it is listening, and force-shut a server that finishes binding after stopServer() has run. Bind failures now reject rather than throwing inside the callback, so they reach start.js's existing catch instead of surfacing as an uncaughtException. The late-callback spec asserted log.info was never called; startServer now legitimately logs on bind, so it asserts on the shutdown message instead. Closes #745 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
| server.addService(itemsProto.ItemService.service, { | ||
| getAll: createGetAllHandler(world), | ||
| }); | ||
| stopping = false; |
There was a problem hiding this comment.
Non-blocking. The shared stopping flag is reset by any startServer() call. That's safe today: start.js is sequential, and no async gap exists where a signal could land between stopServer() and a fresh start. But start (bind pending) → stop → start would clear the flag before the first bind's callback runs. The first bind would then become server and the second would overwrite it, orphaning a listener again, which is the same class of bug this PR fixes.
A per-attempt token avoids resetting shared state:
let generation = 0;
// startServer
const attempt = generation;
// ...in the bind callback
if (attempt !== generation) { pending.forceShutdown(); ... }
// stopServer
generation += 1;If you'd rather keep the boolean, a one-line comment noting it assumes a single start per process would be enough.
There was a problem hiding this comment.
Adopted in 76f0057, and this was worth more than non-blocking — I confirmed the failure rather than reasoning about it.
Traced exactly as described:
startServer()feat(reorganize): Files organized by feature. Initial test coverage a… #1 setsstopping = false, bind feat(reorganize): Files organized by feature. Initial test coverage a… #1 in flight.stopServer()setsstopping = true, finds noserver, resolves.startServer()feat(graphql): ECMAScript 6 refactoring. #2 resetsstopping = false, bind feat(graphql): ECMAScript 6 refactoring. #2 in flight.- Bind feat(reorganize): Files organized by feature. Initial test coverage a… #1's callback sees
stopping === false, soserver = pending#1. - Bind feat(graphql): ECMAScript 6 refactoring. #2's callback also sees
false, soserver = pending#2— andpending#1is now unreachable.
That is the same orphaned-listener bug this PR exists to remove, reintroduced by the fix's own shared state. Unreachable today, since start.js starts once and nothing restarts the server, but it is a latent instance of the exact class being fixed, and leaving it in would have been the wrong trade.
Went with the generation counter rather than the comment. stopServer() increments generation; startServer() captures const attempt = generation and the bind callback force-shuts when attempt !== generation. No shared state is reset anywhere, which is what made the boolean fragile.
Added a regression test for the sequence — start (bind pending) → stop → start → both binds complete — asserting forceShutdown is called exactly once and only one server ever logs as listening. Mutation-checked: reverting to the shared boolean fails 2 tests, dropping the guard fails 2, and never incrementing generation fails 2.
Full suite green: 60 files, 906 passed, 1 skipped.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
| return; | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
Nit. This comment describes the pre-fix behaviour. After this change, a shutdown during the bind doesn't clear the module reference, because server isn't assigned until after this check. Suggest something like: "A shutdown that ran while this bind was in flight found no server to drain. Close this one here rather than leave it listening with nothing able to reach it."
There was a problem hiding this comment.
Fixed in 76f0057. You're right that it described the pre-fix behaviour — server isn't assigned until after the guard now, so there is no module reference left to clear.
Used your wording, with one added sentence explaining why the token is per-attempt rather than a shared flag, since that is the non-obvious part after the other change in this commit:
/**
* grpc-js only registers a listening server inside its own bind callback, so a
* shutdown that ran while this bind was in flight found no server to drain.
* Close this one here rather than leave it listening with nothing able to
* reach it. Comparing against a per-attempt token rather than a shared flag
* keeps a later startServer() from adopting an earlier attempt's server.
*/🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Review feedback on #746. The shared `stopping` boolean was reset by every startServer() call, so start (bind pending) -> stop -> start cleared it before the first bind's callback ran. Both binds then passed the guard: the first became `server` and the second overwrote it, orphaning a listener. That is the bug class this PR exists to remove. Compare a per-attempt token against a module counter that stopServer() increments instead, so no shared state is reset and an attempt that was stopped can never be adopted by a later start. Also correct the bind-callback comment, which described the pre-fix behaviour: a shutdown during the bind now finds no server to drain rather than clearing the module reference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
| log.info({ port }, 'GRPC server is listening'); | ||
| pending.bindAsync(`127.0.0.1:${port}`, grpc.ServerCredentials.createInsecure(), err => { | ||
| if (err) { | ||
| reject(err); |
There was a problem hiding this comment.
If a bind fails after stopServer() has run, this still rejects. The rejection reaches start().catch(exitOnError) and calls process.exit(1) in the middle of the terminus teardown, cutting short cleanup of the cache, job queue, worker pool and subscriber, and turning a normal SIGTERM into a non-zero exit. It's unlikely on 127.0.0.1, but it's the same class of shutdown race this PR fixes.
With the generation token in place, the fix is small:
if (err) {
if (attempt !== generation) {
log.warn({ err }, 'GRPC bind failed after shutdown began; ignoring');
resolve();
return;
}
reject(err);
return;
}A spec could mirror "closes a server that finishes binding after shutdown", calling bound(new Error('EADDRINUSE')) in place of bound(null, 1102) and asserting that startServer() resolves and log.warn is called.
There was a problem hiding this comment.
Adopted in 9091321. Traced the chain to confirm it, and it lands exactly as you describe.
await grpcStart() in start.js:11 means a rejected startServer() rejects start(), which hits start().catch(err => exitOnError(err)) at start.js:23, and exitOnError is console.error followed by process.exit(1). During terminus teardown that exits the process while processExternalPromisesWithTimeout is still inside its 3s window, so the cache, job queue, worker pool and subscriber cleanup are cut short — and a routine SIGTERM reports a non-zero exit. That is the same shutdown race in a different guise, and the generation token was already in place to distinguish the cases.
Took your patch as written. An attempt whose generation has been superseded has nobody waiting on it, so logging and resolving is right, and it matches what the success path already does after a shutdown.
Added the spec you sketched — bound(new Error('EADDRINUSE')) after stopServer(), asserting startServer() resolves, log.warn fires, and forceShutdown is not called (there is no listening server to close in this path).
Mutation-checked both directions, since a guard like this can fail by being too narrow or too broad:
| Mutation | Result |
|---|---|
| remove the guard (reject unconditionally) | 1 failed — the new spec |
| swallow every bind error (never reject) | 1 failed — the existing rejects when the port cannot be bound spec |
The second matters as much as the first: it proves the guard is scoped to post-shutdown binds and has not quietly disabled the startup failure path.
Full suite green: 60 files, 907 passed, 1 skipped.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Review feedback on #746. A bind that failed after stopServer() had run still rejected, and that rejection reaches start.js's catch, which calls process.exit(1). During terminus teardown that cut short cleanup of the cache, job queue, worker pool and subscriber, and turned a normal SIGTERM into a non-zero exit. Nothing is waiting on a server whose attempt has already been superseded, so log and resolve instead of rejecting. Bind failures during a normal start still reject, which is what surfaces them through start.js. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Closes #745. Follow-up to #744, which scoped this out to keep startup behaviour unchanged.
Summary
startServer()assigned the module-levelserverbeforebindAsynccompleted and returnedundefined, sostart.js:11'sawait grpcStart()awaited nothing and a signal arriving during the bind window skipped the drain entirely.grpc-js registers a listening server inside its own bind callback (
node_modules/@grpc/grpc-js/build/src/server.js:419), so a shutdown in that window foundhttp2Serversempty, fired its callback synchronously, logged'GRPC server shut down'having drained nothing, and cleared the module reference. The in-flight bind then completed and registered a listening socket nothing could reach — neithertryShutdownnorforceShutdown— which kept accepting RPCs for the remainder ofonSignal(up to the 3sprocessExternalPromisesWithTimeoutbudget) while the cache, job queue, worker pool and subscriber were being torn down underneath it.startServer()returns a promise that resolves in the bind callback and rejects on bind error.serveris assigned only once the port is actually listening.stoppingflag makes a bind that completes afterstopServer()force-shut its own server instead of orphaning it.Bind failures now reject instead of throwing inside the callback, so they reach
start.js's existing.catch(exitOnError)rather than surfacing as anuncaughtException.No change to
start.js— it already awaited and already had the catch; theawaitis simply real now.Test change worth flagging
The pre-existing late-callback spec asserted
log.infowas never called.startServer()now legitimately logs'GRPC server is listening'on bind, so that blanket assertion was tightened to the one it actually meant:not.toHaveBeenCalledWith('GRPC server shut down').Verification
pnpm lint:ci,pnpm typecheckclean. Full suite: 60 files, 905 passed, 1 skipped.Mutation-checked — every mutation fails the suite:
stoppingguard (orphan the late bind)serverbefore the bind completes (restore the original race)stopping = trueinstopServerThe second is the one that matters: reinstating the exact bug this PR fixes fails the suite.
Risk
Low, but this is startup-path code. The behavioural change is that
start()now blocks until the gRPC port is bound, where previously it returned immediately — so a bind failure is now a startup failure rather than an async throw. That is the intended correction, but it is the line to watch on deploy.🤖 Generated with Claude Code
https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf