Skip to content

C11-26: route blocking v2 socket methods off main (closes 4×/day deadlock) - #112

Merged
BenevolentFutures merged 10 commits into
mainfrom
c11-26-route-blocking-v2-off-main
May 4, 2026
Merged

BenevolentFutures merged 10 commits into
mainfrom
c11-26-route-blocking-v2-off-main

Conversation

@BenevolentFutures

Copy link
Copy Markdown
Contributor

Summary

Fixes the recurring main-thread deadlock in c11 0.44.1 (4 hangs in a single day) where surface.send_text would park CFRunLoopRun() on the main thread inside v2AwaitCallback, beach-balling the whole app with no recovery. Hand-port of upstream cmux PR #3340 (commit 2597d88b by Lawrence Chen, merged 2026-04-30) plus c11-specific scope expansion to actually cover the surface.* methods (upstream's allowlist did not include any of them).

Lattice ticket: C11-26 — Route blocking v2 socket methods off main (port upstream cmux #3340). Continues C11-7 scope items 5+6.

What changed

  • Architecture: new SocketCommandExecutionPolicy enum (mainActor vs socketWorker) + socketWorkerV2Methods allowlist + parseV2SocketRequest / processCommandUsingSocketExecutionPolicy dispatcher. Worker-policy methods run nonisolated on the socket worker thread; only bounded slices hop to @MainActor via Task { @MainActor in ... } + DispatchSemaphore.
  • Migrated to socketWorker policy: surface.send_text, surface.send_key, surface.read_text, surface.clear_history. The four handlers that wrap v2MainSync { ... waitFor* ... } and could deadlock.
  • New helper: waitForTerminalSurfaceOffMain — observer-then-recheck-then-DispatchSemaphore pattern that is correct on a worker thread (the main queue stays free, observers fire, semaphore signals). Legacy waitForTerminalSurface and v2AwaitCallback are untouched (per ticket non-goals); existing main-actor callers keep working.
  • DEBUG instrumentation: dlog(\"v2.<method> isMain=… tid=…\") at the dispatcher seam (forensics for off-main verification) + assertionFailure on invalid_dispatch so a routing-bug regression fails loud at dev/CI time.
  • C11-7 substrate preserved: v2MainSyncWithDeadline and kTier1MainThreadDeadlineSeconds are unchanged. C11-26 routes the v2MainSync-wrapping family off main entirely so the deadline bridge no longer has to absorb their hangs.

Commits (10)

5 implementation commits (401264a5..0a06e208), 4 review-pass commits applying Trident findings (ae417b30, 85d9209a, 8b3a2236, 474083df), 1 absorb-pass commit for trivial Trident escalations (6d4f1c8d).

Validation evidence

  • Tagged Debug build c11-26-fix (PID 66809, socket /tmp/c11-debug-c11-26-fix.sock) green via ./scripts/reload.sh --tag c11-26-fix.
  • Regression test tests_v2/test_v2_surface_send_text_no_main_hang.py: PASS both scenarios (single-call 85ms / 3000ms budget; 20-way concurrent burst 30ms wall / 5000ms budget).
  • Main-thread sample under 50× surface.send_text load (notes/c11-26-validate-sample-fix-20260503-2237.txt, 5s @ 1ms): 0 v2AwaitCallback / 0 v2MainSync / 0 waitForTerminalSurface* anywhere. Pre-fix had 7120/7120 ticks under that exact tree (notes/hangs/sample-2026-05-03-19-09-c11-0.44.1.txt). New architecture observable on 13 worker threads parked in _dispatch_semaphore_wait_slow.
  • Typing-latency-sensitive paths (per c11 CLAUDE.md Pitfalls — WindowTerminalHostView.hitTest, TabItemView, TerminalSurface.forceRefresh) untouched; diff scoped to Sources/TerminalController.swift regions (processV2Command / socketWorker* / v2Surface*) plus the new tests_v2 test file.

Full Validate report: notes/c11-26-validate-report.md (includes ~2-min operator smoke-test instructions).
Trident review pack: notes/trident-review-C11-26-pack-20260503-2146/ (9 reviews + 4 syntheses; verdict fix-then-merge, all Apply-by-default items applied).

Out-of-scope, deferred to follow-up tickets

Per delegator routing of Trident escalations:

  • S1 socketCommandFocusAllowanceStack thread-locality (pre-existing global static; no migrated handler reads it today).
  • S2 test does not exercise the waitForTerminalSurfaceOffMain slow branch (gated on CMUX-37 workspace.apply or a DEBUG-only socket detach helper; 9/9 reviewer consensus).
  • S4 phaseASema.wait() is unbounded (could extend C11-7 v2MainSyncWithDeadline substrate to migrated handlers).
  • E1 extract runOnMainAndWait<T> helper to deduplicate the Phase A/B choreography (3/3 evolutionary reviewers; explicitly held back by operator routing).
  • E2/E3 (architectural arc work): pick next migration target deliberately + generalize the wait helper into awaitNotification(name:object:timeout:).

Test plan

  • Build: ./scripts/reload.sh --tag c11-26-fix green
  • Regression test PASS both scenarios
  • Main-thread sample under load: deadlock tree absent (0 v2AwaitCallback)
  • Typing-latency smoke check: no visible regression
  • Operator smoke-test per notes/c11-26-validate-report.md (~2 min)

References

  • Lattice ticket: C11-26 (`task_01KQR3GKVSGZE6CPT58521V1W8`)
  • Parent: C11-7 (Automation socket reliability)
  • Upstream reference: cmux PR #3340 (commit `2597d88b`)
  • Pre-fix hang artifact: `notes/hangs/sample-2026-05-03-19-09-c11-0.44.1.txt`

BenevolentFutures and others added 10 commits May 3, 2026 20:28
Introduce SocketCommandExecutionPolicy + V2SocketRequest and a
nonisolated dispatcher (parseV2SocketRequest, socketWorkerV2Methods,
executionPolicy(forV2Method:), socketWorkerV2ResponseIfNeeded,
socketWorkerV2Response, processCommandUsingSocketExecutionPolicy) so
the per-client socket loop can route specific v2 methods directly on
the worker thread instead of always hopping to @mainactor first.

Wire handleClient through processCommandUsingSocketExecutionPolicy and
add an invalid_dispatch guard inside processV2Command so any worker-
policy method that mistakenly reaches the main-actor handler errors
loudly instead of silently re-entering the deadlock path.

The allowlist (socketWorkerV2Methods) is empty in this commit; every
method still flows through the legacy v2MainSync { processCommand(...) }
fallback. Subsequent commits add the surface.* family that actually
needs the off-main routing. Mark withSocketCommandPolicy and the v2
encoding helpers (v2OrNull, v2Ok, v2Error, v2Result, v2Encode)
nonisolated so they can be called from the new dispatcher.

Refs: upstream cmux 2597d88b (hand-port; c11 lacks the auth/feedback/
feed/vm prerequisites and uses a slimmer per-client read loop).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
surface.send_text now runs on the socket worker thread via the
SocketCommandExecutionPolicy.socketWorker policy. The old shape wrapped
the entire body in `v2MainSync { ... }` and called
`waitForTerminalSurface` (→ v2AwaitCallback) from inside that block;
when the surface was not yet attached, v2AwaitCallback took its
main-thread branch and entered a nested CFRunLoopRun, which could
never observe its own `DispatchQueue.main.asyncAfter` timeout because
the main dispatch queue was held by the outer `v2MainSync.sync` block.
That is the C11-26 hang (sample-2026-05-03-19-09).

New shape:

  Phase A (Task @mainactor + DispatchSemaphore): resolve tabManager,
  workspace, surfaceId, terminalPanel; capture the surface if already
  attached. v2Ref / v2OrNull are called here so the response envelope
  is built while we are on the main actor. No notification waits in
  this phase; it cannot deadlock.

  Phase B (worker thread): if the surface was not attached, call the
  new `waitForTerminalSurfaceOffMain` helper. It registers the same
  NotificationCenter observers as the legacy `waitForTerminalSurface`
  (queue: .main), but blocks the worker on a DispatchSemaphore instead
  of nesting a CFRunLoopRun on main — observers fire on the
  still-free main queue and the semaphore times out cleanly. Once the
  surface is ready (or the wait elapses), re-hop to @mainactor for
  `sendSocketText` + `forceRefresh`. On timeout, fall through to the
  existing `terminalPanel.sendText` queue path.

The user-visible response envelope is unchanged (workspace_id /
workspace_ref / surface_id / surface_ref / window_id / window_ref;
queued flag is preserved via the same code path; dlog shape and field
set unchanged). A DEBUG `dlog("v2.surface.send_text isMain=…")` line
is added at the top of the handler so off-main routing is grep-able
from logs if a future hang regresses.

Drops the `case "surface.send_text"` arm in processV2Command (it is
unreachable now: the executionPolicy guard above the dispatch already
returns `invalid_dispatch` for any worker-policy method that mistakenly
reaches the main-actor handler) and replaces it with a comment.

Refs: upstream cmux 2597d88b for the policy/dispatcher infra; the
allowlist contents and the Phase A/B handler shape are c11-specific
(upstream did not migrate any surface.* method).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Same shape as the surface.send_text migration: Phase A on @mainactor
to resolve refs and pre-build the response envelope, Phase B on the
worker thread to optionally wait for surface attach (via the
non-nesting waitForTerminalSurfaceOffMain helper), then a final
@mainactor task for the actual sendNamedKey + forceRefresh.

surface.send_key wrapped its body in v2MainSync and called
waitForTerminalSurface from inside it — the same deadlock pattern
that fired for surface.send_text in the captured 2026-05-03 hang.
Migrating it now keeps the surface.* family uniform and removes the
second copy of the deadlock vector before it gets exercised.

Adds the allowlist entry, the dispatcher case in
socketWorkerV2Response, and a comment in processV2Command's switch
explaining where the case went. Marks v2String nonisolated (it does
no MainActor state access; just trims a string) so the param
validation can happen on the worker thread before Phase A.

A DEBUG dlog at the top of the handler tags off-main routing for
forensics, matching the pattern added for surface.send_text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Neither handler has the deadlock vector — neither calls
waitForTerminalSurface, neither waits on a notification — but moving
them to the same socketWorker policy keeps the surface.* family
uniform and makes future audits ("which v2 handlers run on main?")
trivial.

Single-phase migration: one Task @mainactor + DispatchSemaphore wraps
the existing body verbatim, with no Phase B because there is nothing
to wait on. The handler bodies are unchanged from a behavior
standpoint — same param resolution, same response envelope, same
error codes.

Marks v2Bool and v2Int nonisolated so the read_text param validation
can run on the worker thread before the @mainactor block (matches the
pattern already used for v2String).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tests_v2/test_v2_surface_send_text_no_main_hang.py covering:

  1. surface.send_text completes within a 3.0 s wall-clock budget
     while a background thread keeps the main queue under pressure
     (50 system.tree calls in a tight loop). Pre-fix this could hang
     indefinitely if the surface was momentarily detached during a
     layout reshuffle; post-fix surface.send_text runs on the worker
     pool and the wait is bounded.

  2. 20 parallel surface.send_text calls (each on its own socket
     connection) all return within a 5.0 s wall-clock budget,
     exercising the off-main routing under concurrent load.

The test follows c11 CLAUDE.md "Test quality policy": end-to-end via
the real socket, no source-grep assertions, no plist/pbxproj reads.

Per c11 CLAUDE.md "Testing policy", this test runs against a tagged
debug build only — never an untagged `c11 DEV.app`. Header docstring
documents the C11_SOCKET environment variable for the tagged build
socket the delegator produces (e.g. /tmp/c11-debug-c11-26-fix.sock).

Per the plan's note on the not-yet-attached precondition: a clean
repro of the original detached-surface deadlock requires a
workspace.apply primitive (CMUX-37 territory) that c11 doesn't have
yet. The single-call scenario uses parallel main-actor pressure to
stress the same dispatch path; the burst scenario broadens coverage.
A future test extension can target the not-yet-attached case
specifically once workspace.apply lands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Trident-flagged blocker B1: worker-policy methods bypass
processV2Command's `v2MainSync { v2RefreshKnownRefs() }` (line 2132),
so a fresh `surface:N` / `workspace:N` ref handle is unresolved on
the first worker call and `v2UUID(...)` silently falls back to the
focused panel — meaning text/keys can be injected into the wrong
terminal.

Add v2RefreshKnownRefs() as the first call inside the @mainactor
block of resolveSurfaceSendTargets, v2SurfaceClearHistory, and
v2SurfaceReadText so the handle map is current before any
v2UUID/v2ResolveWorkspace/v2ResolveTabManager call.

Sources: trident synthesis-action.md B1 (Critical Codex).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Trident-flagged blocker B2: Phase A snapshots terminalPanel.surface.surface
on @mainactor, then the worker schedules a separate Task @mainactor for
Phase B's sendSocketText / sendNamedKey. Between the two main-actor turns,
TerminalSurface.teardownSurface() (also @mainactor — nils-then-frees the
ghostty_surface_t) can run. Phase B then calls sendSocketText / sendNamedKey
on a freed pointer — undefined behavior, plausible segfault under workspace
close + concurrent send_text/send_key.

Pre-fix code re-resolved the surface inside one v2MainSync block, so the
window did not exist; the C11-26 split into two main-actor hops opens it.

Re-read resolved.terminalPanel.surface.surface inside Phase B's
@mainactor block in v2SurfaceSendText and v2SurfaceSendKey. If the live
pointer is non-nil, use it. If nil, fall through to the pending queue
(send_text) or return the existing "Surface not ready" error (send_key).

Sources: trident synthesis-action.md B2 (Critical Claude, Critical Codex,
Evolutionary Codex).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Trident-flagged importants:
- I1 (comment update): The handler comment on v2SurfaceSendText and the
  test docstring still described the migrated path as using
  v2AwaitCallback's worker-thread semaphore branch. The implementation
  uses a parallel waitForTerminalSurfaceOffMain helper (the ticket
  non-goals protect v2AwaitCallback's @mainactor callers from
  repointing). Updated both comments to name the helper actually in use.
- I2 (dead param): resolveSurfaceSendTargets accepted an
  errMessageOnInternalError String parameter that the body never read
  (every error path returned its own message). Removed the parameter
  and both call sites.

Sources: trident synthesis-action.md I1 (Standard Codex), I2 (Critical
Claude + Critical Codex two-reviewer consensus).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Trident-flagged straightforward mediums:
- M1 (dlog promotion): every migrated handler opened with the same
  `#if DEBUG dlog("v2.<method> isMain=\(...)") #endif` block. Lift the
  diagnostic to socketWorkerV2Response so future migrated methods get it
  for free instead of "we forgot." Behaviorally identical.
- M2 (DEBUG assertionFailure on invalid_dispatch): if a worker-policy
  method reaches processV2Command (routing bug), we previously returned
  an invalid_dispatch error string and continued. In DEBUG, also dlog
  the violation and call assertionFailure so CI catches the regression
  before it ships. Release-mode behavior unchanged.

Sources: trident synthesis-action.md M1 (Evolutionary Claude),
M2 (Evolutionary Claude).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
S3: replace 200ms hardcoded sleep in _seed_workspace_and_surface with a
50ms-step poll up to 2s. Cuts CI variance noise without changing the
contract the surrounding tests rely on.

S5: dlog the slow path in waitForTerminalSurfaceOffMain when
requestBackgroundSurfaceStartIfNeeded is invoked. Pure observability —
makes slow send_text traces in dev/CI obvious about what they're
waiting on.

S6: comment the off-main read of TerminalSurface.surface — the recheck
post-observer-registration closes the race window, the off-main load
is acceptable on Darwin given pointer-sized atomicity, and a future
@mainactor migration of TerminalSurface.surface must revisit this
helper. Five Trident reviewers flagged this as document-don't-block.

S8: docstring runner reference updated from gh workflow run
test-e2e.yml to scripts/run-tests-v2.sh, matching the actual runner.
@BenevolentFutures
BenevolentFutures merged commit 862c8cd into main May 4, 2026
4 of 6 checks passed
BenevolentFutures added a commit that referenced this pull request May 4, 2026
…xes EXC_BREAKPOINT crash, 4× today) (#121)

* C11-26 followup: route bare main.sync handlers through v2MainSync (closes EXC_BREAKPOINT crash)

After C11-26 (#112), the new socket dispatcher routes default-policy
commands through `DispatchQueue.main.sync { MainActor.assumeIsolated {
processCommand(...) } }` from the worker thread. That moves every v1
handler (and every default-policy v2 handler) onto the main thread —
a behavior change vs. pre-C11-26 where `handleClient` called
`processCommand(trimmed)` directly on the worker.

Roughly 100 handlers in `TerminalController` and 12 in
`ThemeSocketMethods` were written against the old assumption: each does
its own `DispatchQueue.main.sync { … }` to hop onto main. Post-C11-26
that hop is reentrant — libdispatch's self-deadlock guard
(`__DISPATCH_WAIT_FOR_QUEUE__`) traps with EXC_BREAKPOINT. The
operator hit this 4× today on builds 95/96/97 — once each from DEV-main
at 14:41 and 15:01 EDT, then on 0.45.0 at 15:56, then on 0.45.1 at
18:49 — every dump bottoms out at `setProgress(_:) + 924` →
`closure #1 in processCommand(_:) + 3856` → `__DISPATCH_WAIT_FOR_QUEUE__
+ 484`. The earlier 14:26 IPS hang on 0.44.1 (build 95) is the same
class of bug pre-detection: same dispatcher path, same self-wait,
but on an older libdispatch that hung instead of trapping.

Fix: replace the bare `DispatchQueue.main.sync { … }` calls with the
existing `v2MainSync` helper (TerminalController) and a parallel
`Self.mainSync` helper (ThemeSocketMethods). Both short-circuit when
already on main and only hop when called from a worker, so the same
handler is correct under either dispatcher path. The
`ThemeSocketMethods` enum is not `@MainActor`, so its helper takes a
`@MainActor () -> T` body and uses `MainActor.assumeIsolated`, mirroring
`GhosttyTerminalView.performOnMain`.

The dispatcher comment in `TerminalController` (lines 1639–) is updated
to call out the behavior change explicitly so future handler authors
know not to use bare `DispatchQueue.main.sync`.

The two intentional bare-`DispatchQueue.main.sync` sites are kept:
  - line 1734 (the dispatcher itself, which must hop from worker)
  - line 3366 (inside `v2MainSync`'s own implementation)

Other call sites in the tree (`TabManager.swift:586`,
`Workspace.swift:5656`, `GhosttyTerminalView.swift:1891`) already gate
on `Thread.isMainThread` and are unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* C11-26 followup: regression test — v1 handlers don't self-deadlock on main

Adds tests_v2/test_v1_handler_main_self_deadlock.py covering two
scenarios:

  1. 30 successive `set_progress` calls on a fresh workspace, each on a
     fresh socket connection. Pre-fix this trapped EXC_BREAKPOINT inside
     libdispatch's __DISPATCH_WAIT_FOR_QUEUE__ on the first call;
     subsequent connects then failed with ECONNREFUSED because the
     listener was gone. Post-fix all 30 return "OK".

  2. A spread of v1 commands that share the bare-`DispatchQueue.main.sync`
     shape (`set_status`, `clear_status`, `report_pwd`, `clear_progress`,
     `report_git_branch`, `clear_git_branch`). They all route through
     `processCommand` → switch case → bare main.sync (pre-fix). The
     dispatcher path is the load-bearing piece; covering several anchors
     means a regression on the dispatcher cannot regress only one sibling.

The test follows c11 CLAUDE.md "Test quality policy": runs through the
real socket, no source-grep assertions, no plist/pbxproj reads. Per
"Testing policy", runs against a tagged debug build only — header
docstring documents `C11_SOCKET=/tmp/c11-debug-<slug>.sock`.

A regression that produces only a hang (older libdispatch) is also caught
by the per-call wall-clock deadline; one that produces a crash is caught
by the next-connection ECONNREFUSED check. Both shapes route through the
same assertion ("the call returned OK and the next connect succeeds"),
which is what the user actually cares about.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <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