fix(hook-server): a confirmation-gated control request must not be killed by the receive-phase timeout - #158
Merged
Merged
Conversation
…lled by the receive-phase timeout The 2s slowloris guard was armed for the whole request lifetime, not just the receive phase. A destructive /control/ verb parks in the renderer for as long as the user takes to answer the confirmation dialog (up to the desktop's 120s pendingControl bound), so the guard destroyed that socket mid-dialog: the sh shim reported "control endpoint unreachable" (curl exit 52 at ~2019ms) while a late confirm STILL delivered the text. The agent was told nothing happened when it had, and may retry — a silent duplicate delivery. The guard is RAISED, not removed. Dropping it entirely (setTimeout(0)) leaves /control/ bounded only by pendingControl, a bound that lives outside core, so a future core-side handler would inherit an unbounded socket — and it leaves /context-link/ with no bound at all, where a remote read over a wedged ControlMaster can hang forever (ConnectTimeout only covers connect). Pre-fix, the 2s destroy at least unblocked the agent's curl. So both routes hand off to a named CONTROL_CEILING_MS (130s, comfortably above the desktop's 120s, so the handler's own timeout always wins in practice), and /context-link/ additionally races its handler at 30s, answering with the same "Could not read linked context." prose any other read error yields. Carries the fix half of #113 by @austinschneider; that PR's seamless-writes setting is being redesigned separately and is deliberately not included. Co-Authored-By: austinschneider <hogenshpogen@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each case has to sit out the 2s guard in real time, so serially the file parked for 7s of pure wall clock on every suite run. The two routes are independent, so running them concurrently halves that (7.3s -> 3.8s) and takes the scheduling pressure off the files sharing the machine. Co-Authored-By: Claude Opus 5 (1M context) <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.
Reproduction
An agent issues a canvas-control
writeto another node. The user takes more than 2 seconds to answer the confirmation dialog. The shim reports:curl exits 52 (empty reply from server) at ~2019 ms — while the dialog is still on screen. A late confirm STILL delivers the text. So the agent is told the endpoint was unreachable when the write actually landed, and may retry: silent duplicate delivery. Stacked confirmations wedge agent messaging entirely.
The cause is that the hook server's 2 s slowloris guard (
req.setTimeout(SLOWLORIS_MS, () => req.destroy())) was armed for the whole request lifetime, not just the receive phase. A destructive/control/verb parks in the renderer by design, up to the desktop's 120 spendingControlbound (src/main/index.ts) — far past 2 s.The fix
The guard is a RECEIVE-phase guard: it exists for a client that dribbles bytes to pin a socket, not for the handler. So once the body is fully read, both the
/control/and/context-link/routes hand the socket off from the guard to a much larger ceiling.Why a bounded ceiling, not
setTimeout(0)Removing the ceiling outright would be a real behavior regression in two places:
/control/would then be bounded only bypendingControl's 120 s — a bound that lives outside core, in the desktop shell. A future core-side control handler would silently inherit an unbounded socket./context-link/would get no bound at all.handleContextLinkRequesthas no timeout of its own, and its remote leg reads over an SSH ControlMaster that can wedge (ConnectTimeoutonly covers the connect). Pre-fix, the 2 s destroy at least unblocked the agent's curl; with no ceiling the agent's session blocks indefinitely — a worse failure than the one being fixed.So:
CONTROL_CEILING_MS = 130_000— a named constant sitting comfortably above the desktop's 120 s bound, so in practice the handler's own timeout always wins and this only ever fires as a backstop. Applied to both routes. A confirmation-gated handler may legitimately park; nothing may park forever./context-link/additionally races its handler at 30 s (CONTEXT_LINK_READ_MS), resolving to the existing"Could not read linked context."string. The race is the effective bound there; the socket ceiling is only the backstop behind it. The helper also swallows a rejection — it must, or a read that rejects after the timeout already answered surfaces as an unhandled rejection — which as a side effect turns a rejecting handler into that same prose failure instead of a bare 204.Test
src/core/agents/hook-server.slowloris.test.tsspins the realhookServer(same harness precedent ascontext-link.cli.test.ts) with handlers that park 3.5 s — past the 2 s guard — and asserts both routes still answer 200 with their payload.Verified by stashing the production change:
TypeError: fetch failed/SocketError: other side closed(UND_ERR_SOCKET) on both routesGates
npm run typecheck— cleannpx vitest run src/core/agents src/core/context-link.cli.test.ts src/main/canvas-control-core.test.ts src/main/canvas-control-shim.test.ts— 13 files, 172 passednpx vitest run— 396 passed / 1 failed (398 files), 5114 passed / 3 failed (5129 tests). The 3 failures are allsrc/main/node-pty-patch.test.ts, environmental in this clone (symlinkednode_modules); confirmed identical on the untouched tree atorigin/main.This carries the fix half of #113, with credit to @austinschneider (
Co-Authored-Byon the commit). That PR'sagentSeamlessWritessetting and its Canvas write-path bypass are not included here — that half is being redesigned separately. #113 is left open for the owner; our fork policy is fast-forward-only on contributor branches, so the fix half is landed here rather than by rewriting theirs.🤖 Generated with Claude Code