fix(sandbox): don't answer a DNS query after the proxy has closed - #282
Open
oratis wants to merge 1 commit into
Open
fix(sandbox): don't answer a DNS query after the proxy has closed#282oratis wants to merge 1 commit into
oratis wants to merge 1 commit into
Conversation
`close()` closed the server socket, but `forward()` held that same socket as `serverSock` across the await on the upstream reply. A reply landing after close called `serverSock.send()` on a closed socket, which dgram throws from synchronously — inside the upstream 'message' handler, past the enclosing executor's synchronous phase, so the promise never caught it. It escaped as an uncaught ERR_SOCKET_DGRAM_NOT_RUNNING and failed the whole @deepcode/core vitest run. The `.catch()` fallback send at the call site had the same hazard. A shared ProxyState now carries a `closed` flag, set synchronously before `sock.close()`, plus a map of in-flight forwards. Forwards check the flag before touching the server socket and resolve instead; `close()` abandons each pending forward, releasing its upstream socket and clearing its 5s timer rather than leaving them to hold the event loop open. `upstreamPort` (default 53) is the test seam: the port was hardcoded, so a stub upstream would otherwise need a privileged bind. netns.ts does not pass it, so its behavior is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
What broke
The
Typecheck + Lint + Test (ubuntu-latest)job fails intermittently. Seen on #277 (run 32865446283); re-running the same commit passed, so it is timing-dependent and not caused by that PR. Linux-only becausenetns-integration.test.tsonly runs where bwrap/slirp4netns exist.Vitest reports it as an unhandled error, not a failed assertion, so the whole
@deepcode/corerun fails.Why
startDnsProxyreturns a handle whoseclose()closes the server socket. Butforward()captures that same socket asserverSockand holds it across the await on the upstream reply. When the proxy is closed mid-forward, the lateupSock.once('message')handler callsserverSock.send(...)on a closed socket — and dgram throws from that synchronously, inside the event handler, past the enclosing Promise executor's synchronous phase. The surrounding promise never sees it.The
.catch()fallback at theforward(...)call site had the same hazard: it also sendsbuildNxDomain(msg)on the server socket, which throws the same way if the proxy has closed by the time a forward rejects.The fix
A shared
ProxyState(aclosedflag plus a map of in-flight forwards) now links the server socket to its forwards:close()setsclosed = truebeforesock.close(), then abandons every pending forward. Both run on the same thread, so nothing can slip between a forward's check and its send.forward()checks the flag beforeserverSock.send(...)and resolves instead — the proxy is shutting down and nobody is waiting for that reply..catch()fallback send is guarded the same way.close()releases each one and clears its 5s timer instead of leaving them to hold the event loop open. A singlerelease()now serves the reply, timeout, and error paths.upSock.close()also moved out of theserverSock.sendcallback to just after the reply arrives — the fd is freed sooner and every path shares one teardown.One scope note
forward()hardcodedupSock.send(query, 53, upstream), so a test could only point at a stub upstream by binding a privileged port (root-only on Linux CI).DnsProxyOptsgains an optionalupstreamPort(default53) as that test seam.netns.tsdoes not pass it, so its behavior is unchanged.Tests
Per AGENTS.md, sandbox changes need focused adversarial tests. New
startDnsProxy shutdown raceblock, with a local stub upstream so it does not depend on bwrap and runs on every platform:close()— stub delays 120ms, proxy closes at 40ms; asserts nothing escapes as an uncaught exception. A helper temporarily swaps out vitest's ownuncaughtExceptionhandlers so a regression surfaces as a clean assertion instead of a run-level failure.close()drops the pending socket and timer — stub never answers; assertsprocess.getActiveResourcesInfo()shrinks acrossclose().Against the unfixed code both fail, the first with exactly the CI error:
Verification
pnpm typecheck,pnpm lint,pnpm test(1162 core tests), plusformat:check,docs:check, andbuildall pass. Because the bug is timing-dependent, the dns-proxy file was also run 20× in a row: 0 failures.Two caveats, stated plainly:
netns-integration.test.tsis skipped — the originally failing test was not exercised directly. The fix is platform-independent and the new unit test covers the exact hazard its traceback points at, but the Linux CI run on this PR is what confirms it end to end.🤖 Generated with Claude Code