Skip to content

Fix dispatch thread-pool deadlock that froze worktree loading - #59

Merged
sapsaldog merged 1 commit into
mainfrom
fix/git-executor-dispatch-deadlock
Jun 21, 2026
Merged

sapsaldog merged 1 commit into
mainfrom
fix/git-executor-dispatch-deadlock

Conversation

@sapsaldog

Copy link
Copy Markdown
Owner

Problem

"Loading worktrees…" froze indefinitely for repositories with many worktrees. A sample of the hung app showed the main thread idle but 65 dispatch worker threads all blocked at GitCommandExecutor.execute's DispatchGroup.wait(), with Dispatch Thread Soft Limit: 64 reached — a libdispatch thread-pool exhaustion deadlock. No git child process was running.

Root cause

GitCommandExecutor.execute dispatched each git invocation onto libdispatch's global concurrent pool, then blocked that worker on DispatchGroup.wait() while waiting for two more pooled blocks (the stdout/stderr readers). Once 64 calls ran at once, all 64 pool threads sat in wait() and the reader blocks they depended on could never be scheduled — a permanent deadlock.

The trigger: WorktreeListViewModel.enriched() fans out one git log per worktree with no concurrency bound, so a repo with ~64 worktrees fired ~64 concurrent execute calls and deadlocked the load every time.

Fix (both layers)

  • Root cause — GitCommandExecutor now drains both pipes incrementally via readabilityHandler and resumes through DispatchGroup.notify (never .wait()). No call parks a pool thread, so no concurrency level can exhaust the pool. Output larger than the ~64KB pipe buffer still drains without stalling the child.
  • Defense in depth — enriched() bounds the commit-date fan-out to 8 concurrent lookups via a sliding window.

Tests

  • GitCommandExecutorConcurrencyTests — real-executor stress test (80 concurrent commands) that deadlocked before (60s time-limit) and now completes in ~0.2s, plus characterization tests for stdout capture, non-zero exit codes, and >64KB output draining.
  • WorktreeListViewModelEnrichmentTests — new cap test: 100 worktrees, asserts peak concurrency stays bounded (was 100, now ≤ 8).

Verification

  • swiftlint lint: 0 violations
  • Full suite: 497 tests pass
  • Coverage gate (scripts/coverage.sh): strict 100% on 27 files

🤖 Generated with Claude Code

GitCommandExecutor.execute dispatched each git invocation onto
libdispatch's global pool and then blocked that worker on
DispatchGroup.wait() while waiting for two more pooled reader blocks.
With 64+ concurrent calls, every thread in the 64-thread global pool sat
in wait() while the reader blocks they depended on could never be
scheduled — a permanent deadlock that froze "Loading worktrees…" with no
hung git child process and the main thread idle.

It triggered whenever a repository's worktree count approached the
64-thread limit: WorktreeListViewModel.enriched() fans out one `git log`
per worktree with no concurrency bound, so a repo with ~64 worktrees
deadlocked the load every time.

Fix both layers:

- GitCommandExecutor now drains both pipes incrementally via
  readabilityHandler and resumes through DispatchGroup.notify (never
  .wait()), so no call parks a pool thread and no concurrency level can
  exhaust the pool. Output larger than the ~64KB pipe buffer still
  drains without stalling the child.
- enriched() bounds the commit-date fan-out to 8 concurrent lookups via
  a sliding window — defense in depth plus less process churn.

Tests: a real-executor stress test (80 concurrent commands) that
deadlocked before and now completes in ~0.2s, plus an enrichment cap
test asserting peak concurrency stays bounded (100 worktrees, peak <= 8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sapsaldog
sapsaldog merged commit 4e893d7 into main Jun 21, 2026
2 checks passed
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