Skip to content

fix(sandbox): don't answer a DNS query after the proxy has closed - #282

Open
oratis wants to merge 1 commit into
mainfrom
claude/festive-bardeen-723797
Open

fix(sandbox): don't answer a DNS query after the proxy has closed#282
oratis wants to merge 1 commit into
mainfrom
claude/festive-bardeen-723797

Conversation

@oratis

@oratis oratis commented Aug 26, 2026

Copy link
Copy Markdown
Owner

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 because netns-integration.test.ts only runs where bwrap/slirp4netns exist.

Error: Not running   { code: 'ERR_SOCKET_DGRAM_NOT_RUNNING' }
  at Socket.<anonymous> src/sandbox/dns-proxy.ts:131:18

Vitest reports it as an unhandled error, not a failed assertion, so the whole @deepcode/core run fails.

Why

startDnsProxy returns a handle whose close() closes the server socket. But forward() captures that same socket as serverSock and holds it across the await on the upstream reply. When the proxy is closed mid-forward, the late upSock.once('message') handler calls serverSock.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 the forward(...) call site had the same hazard: it also sends buildNxDomain(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 (a closed flag plus a map of in-flight forwards) now links the server socket to its forwards:

  • close() sets closed = true before sock.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 before serverSock.send(...) and resolves instead — the proxy is shutting down and nobody is waiting for that reply.
  • The .catch() fallback send is guarded the same way.
  • Pending upstream sockets are tracked, so close() releases each one and clears its 5s timer instead of leaving them to hold the event loop open. A single release() now serves the reply, timeout, and error paths.

upSock.close() also moved out of the serverSock.send callback to just after the reply arrives — the fd is freed sooner and every path shares one teardown.

One scope note

forward() hardcoded upSock.send(query, 53, upstream), so a test could only point at a stub upstream by binding a privileged port (root-only on Linux CI). DnsProxyOpts gains an optional upstreamPort (default 53) as that test seam. netns.ts does not pass it, so its behavior is unchanged.

Tests

Per AGENTS.md, sandbox changes need focused adversarial tests. New startDnsProxy shutdown race block, with a local stub upstream so it does not depend on bwrap and runs on every platform:

  1. Late reply after close() — stub delays 120ms, proxy closes at 40ms; asserts nothing escapes as an uncaught exception. A helper temporarily swaps out vitest's own uncaughtException handlers so a regression surfaces as a clean assertion instead of a run-level failure.
  2. close() drops the pending socket and timer — stub never answers; asserts process.getActiveResourcesInfo() shrinks across close().

Against the unfixed code both fail, the first with exactly the CI error:

+   Error { "message": "Not running", "code": "ERR_SOCKET_DGRAM_NOT_RUNNING" }

Verification

pnpm typecheck, pnpm lint, pnpm test (1162 core tests), plus format:check, docs:check, and build all pass. Because the bug is timing-dependent, the dns-proxy file was also run 20× in a row: 0 failures.

Two caveats, stated plainly:

  • This was verified on macOS, where netns-integration.test.ts is 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.
  • This closes the race that traceback identifies. If the job flakes again with a different signature, that is a separate issue.

🤖 Generated with Claude Code

`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>
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.

1 participant