Fix dispatch thread-pool deadlock that froze worktree loading - #59
Merged
Merged
Conversation
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>
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.
Problem
"Loading worktrees…" froze indefinitely for repositories with many worktrees. A
sampleof the hung app showed the main thread idle but 65 dispatch worker threads all blocked atGitCommandExecutor.execute'sDispatchGroup.wait(), withDispatch Thread Soft Limit: 64 reached— a libdispatch thread-pool exhaustion deadlock. No git child process was running.Root cause
GitCommandExecutor.executedispatched each git invocation onto libdispatch's global concurrent pool, then blocked that worker onDispatchGroup.wait()while waiting for two more pooled blocks (the stdout/stderr readers). Once 64 calls ran at once, all 64 pool threads sat inwait()and the reader blocks they depended on could never be scheduled — a permanent deadlock.The trigger:
WorktreeListViewModel.enriched()fans out onegit logper worktree with no concurrency bound, so a repo with ~64 worktrees fired ~64 concurrentexecutecalls and deadlocked the load every time.Fix (both layers)
GitCommandExecutornow drains both pipes incrementally viareadabilityHandlerand resumes throughDispatchGroup.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.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 violationsscripts/coverage.sh): strict 100% on 27 files🤖 Generated with Claude Code