Skip to content

fix(bridge): bound every agent-facing wait — client deadlines, MCP cancel, server connection cap - #253

Merged
initcore0 merged 1 commit into
mainfrom
claude/relaxed-gould-c4cabc
Nov 2, 2026
Merged

initcore0 merged 1 commit into
mainfrom
claude/relaxed-gould-c4cabc

Conversation

@initcore0

Copy link
Copy Markdown
Owner

What & why

Fixes the four agent-facing hangs from the July bug hunt (docs/BUG_HUNT_2026-07.md). In each case an agent such as Claude Code, calling the openwhisp CLI or MCP server, could wait forever, or the mic could stay open long after the agent gave up.

  1. BridgeClient had no timeouts. openwhisp status and the other verbs could hang forever if the app was wedged.
    • Connect, write and response read are now bounded by per-verb deadlines (BridgeTimeouts):
      • 15 s for hello/status/stop/cancel;
      • for guarded verbs: the consent window, plus the verb's budget, plus slack;
      • dictate: derived from its own clamped timeoutSeconds.
    • On expiry it throws ClientError.timedOut(method:seconds:). The CLI exits 2 with a clear message, and MCP returns a tool error.
    • PersistentBridge drops the out-of-sync connection on a timeout, but never retries (the request may still be running).
  2. The MCP server ignored notifications/cancelled. A cancelled openwhisp_dictate left the mic open for up to 300 s.
    • tools/call now runs on a serial worker queue, so stdin keeps being read.
    • MCPRequestTracker handles cancellation:
      • a running dictate is stopped with dictate.cancel over a separate bridge connection, retried while still in flight (for example while waiting on consent);
      • a queued call is skipped;
      • a cancelled request gets no response.
    • Stdin EOF cancels the same way.
  3. AgentBridgeServer had one thread per connection, with no cap and no socket timeouts.
    • Concurrent connections are capped at 32; past the cap the peer gets a busy error frame.
    • Accepted sockets get SO_RCVTIMEO/SO_SNDTIMEO through BridgeConnectionReader (in core): idle connections close after 5 min, stop() is noticed within one 5 s tick, and torn writes shut the socket down.
  4. WorkspaceContext's git rev-parse blocked with no deadline before the MCP keep-alive started. It's now abandoned after 2 s.

The decision logic sits in testable modules (OpenWhispBridgeKit, plus BridgeConnectionPolicy in OpenWhispCore), following CLAUDE.md. docs/BUG_HUNT_2026-07.md marks the four items fixed.

How it was tested

  • swift test passes on the branch: 2,738 tests, as reported by the implementing session
  • Each fix has a test that fails with that fix removed: BridgeClientTimeoutTests, MCPServerCancellationTests, BridgeConnectionPolicyTests, plus additions to PersistentBridgeTests and WorkspaceContextTests
  • Lean app build compiles
  • ./build.sh: this branch was cut before chore(deps): update all native deps + CI, retire WhisperKit fork, ship SpeechAnalyzer again #250's Xcode 27 build fix landed. It merges cleanly with current main, and CI builds the merge.
  • Manually verified with a live agent (cancel a dictate mid-flight, wedged-app status): not yet

Privacy checklist

  • No new network egress. Everything is local UNIX-socket and stdio plumbing.

Notes

  • Behavior changes agents can see:
    • CLI calls that used to hang now fail with exit code 2 after the deadline.
    • A cancelled MCP dictate gets no response, which is what the MCP spec requires.
    • A 33rd concurrent bridge connection gets busy.
  • BridgeConnectionPolicy is a new OpenWhispCore file, registered in Package.swift's explicit sources: list.
  • Independent of fix(refine): reject same-script translations in the cleanup language guard #252 (refine language guard).

🤖 Generated with Claude Code

…ncel, server connection cap

Four agent-facing hangs from docs/BUG_HUNT_2026-07.md:

- BridgeClient: connect, write and response read are now bounded. Each
  call gets a deadline from BridgeTimeouts (15s for hello/status/stop/
  cancel; consent window + the verb's budget + slack for guarded verbs;
  dictate derived from its own clamped timeoutSeconds). Expiry throws the
  new ClientError.timedOut(method:seconds:); the CLI exits 2 with a clear
  message, MCP returns a tool error. PersistentBridge drops the desynced
  connection on a timeout but never retries (the request may still run).

- MCP server: tools/call runs on a serial worker queue so stdin keeps
  being read. notifications/cancelled is handled via MCPRequestTracker:
  a running dictate is stopped with dictate.cancel over a separate bridge
  connection (retried while the dictate is still in flight, e.g. waiting
  on consent), a queued call is skipped, and a cancelled request gets no
  response. Stdin EOF cancels the same way.

- AgentBridgeServer: concurrent connections capped at 32 (over the cap
  the peer gets a `busy` error frame). Accepted sockets get SO_RCVTIMEO/
  SO_SNDTIMEO via BridgeConnectionReader (core): idle connections close
  after 5 min, stop() is noticed within one 5s tick, torn writes shut the
  socket down.

- WorkspaceContext: the git rev-parse that runs before the MCP keep-alive
  starts is abandoned after 2s.

Each fix has a swift test that fails with the fix removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@initcore0
initcore0 merged commit 206f87f into main Nov 2, 2026
11 checks passed
@initcore0
initcore0 deleted the claude/relaxed-gould-c4cabc branch November 2, 2026 08:25
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