Skip to content

fix(hook-server): a confirmation-gated control request must not be killed by the receive-phase timeout - #158

Merged
eneskirca merged 2 commits into
mainfrom
fix/control-slowloris
Aug 13, 2026
Merged

eneskirca merged 2 commits into
mainfrom
fix/control-slowloris

Conversation

@eneskirca

Copy link
Copy Markdown
Owner

Reproduction

An agent issues a canvas-control write to another node. The user takes more than 2 seconds to answer the confirmation dialog. The shim reports:

control endpoint unreachable

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 s pendingControl bound (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 by pendingControl'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. handleContextLinkRequest has no timeout of its own, and its remote leg reads over an SSH ControlMaster that can wedge (ConnectTimeout only 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.ts spins the real hookServer (same harness precedent as context-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:

  • without the fix: 2 failed — TypeError: fetch failed / SocketError: other side closed (UND_ERR_SOCKET) on both routes
  • with the fix: 2 passed

Gates

  • npm run typecheck — clean
  • npx 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 passed
  • npx vitest run — 396 passed / 1 failed (398 files), 5114 passed / 3 failed (5129 tests). The 3 failures are all src/main/node-pty-patch.test.ts, environmental in this clone (symlinked node_modules); confirmed identical on the untouched tree at origin/main.

This carries the fix half of #113, with credit to @austinschneider (Co-Authored-By on the commit). That PR's agentSeamlessWrites setting 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

…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>
@eneskirca
eneskirca merged commit 4803468 into main Aug 13, 2026
4 checks passed
@eneskirca
eneskirca deleted the fix/control-slowloris branch August 13, 2026 10:04
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