Fix UI beachball when a review session spins up#405
Conversation
Review kickoff was running gh/git/clone calls synchronously on the main actor, freezing the app for as long as the worst gh/git step took. SessionService is @mainactor, and the old shell() helper used process.waitUntilExit() — so each await shell(...) blocked the main thread instead of yielding. Two surgical changes: 1. shell() now uses withCheckedThrowingContinuation + process.terminationHandler and is marked nonisolated, so await shell(...) truly suspends the calling task while the subprocess runs in the background. 2. createReviewSession()'s gh/git/file-write block is extracted into a nonisolated static prepareReviewClone() helper that runs inside a Task.detached. The main-actor tail only handles the lightweight appState mutation, store.mutate, and prepareTerminal (still capped at 2s by the existing tmux watchdog). JSONStore.mutate is unchanged — its NSLock is already released before the disk write (#304). The kickoff serialization queue in AppDelegate.enqueueReviewKickoff is unchanged; it still awaits each createReviewSession in order. 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: CF6BE480-B904-41F8-A41B-0825804E67F8
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None.
Summary
The fix correctly addresses the main-actor beachball (#404) with two well-scoped changes:
shell()truly yields — replacingwaitUntilExit()withwithCheckedThrowingContinuation+terminationHandler, and marking itnonisolated.shell()uses no instance state, so dropping main-actor isolation is safe; all existing callers (worktree list,remote get-url,gh issue view,gh pr view,repo clone) stillawaitit correctly.prepareReviewCloneoff-main — thegh/git/file-prep block moves into anonisolated statichelper run viaTask.detached(.userInitiated), leaving only cheap state mutations on the main actor.envis captured asShellEnvironment(public final class … : Sendable), andScaffolder.bundled*/buildReviewPromptare plain non-isolated statics, so the off-main hop is concurrency-clean.
Security Review
Strengths:
- No new external input surface; PR URL parsing and command args are unchanged from the prior implementation. No shell string interpolation — args are passed as an array to
/usr/bin/env, so no command-injection vector is introduced. - Error-message construction now prefers
stderrand falls back tostdout; neither is surfaced to users beyondNSLog, consistent with prior behavior.
Concerns: None.
Code Quality
- Continuation safety is correct.
terminationHandleris installed beforeprocess.run(), so the success path resumes exactly once. Thecatchon launch failure defensively clearsterminationHandler = nilbefore resuming, preventing a double-resume — a nice touch that matches the documented macOS launch-failure edge case. - Behavioral parity preserved. PR-metadata fetch failure aborts kickoff (
throw→ caught →return nil+NSLog), matching the old guard;git fetch/checkout/pullandcloneerrors remain tolerated viatry?. Kickoff serialization inAppDelegate.enqueueReviewKickoffis untouched, so PR ordering is preserved.
Findings
Green — pipe read-after-exit comment is misleading (Sources/Crow/App/SessionService.swift:830-833, 845-853). The doc comment claims reads-after-exit means "no deadlock from a full pipe blocking the child." That reasoning is backwards: draining readDataToEndOfFile only after terminationHandler fires is precisely the pattern that can deadlock if a child writes more than the ~64KB pipe buffer to stdout/stderr and then blocks on write(), never exiting — so the handler never fires and the continuation never resumes. This is not a regression (the old waitUntilExit() path had the identical property) and is harmless for the current callers, whose output is small (gh/git to a non-tty emit minimal progress). But since runShellAsync is now a reusable static helper, consider either correcting the comment or draining the pipes concurrently (e.g. readabilityHandler/background reads) before reusing it for high-volume commands like gh pr diff on a large PR.
Green — no direct unit coverage for runShellAsync. Subprocess runners are awkward to unit-test, and the existing 145-test suite exercises the surrounding paths, so this is acceptable; a small test against a trivial /usr/bin/env true/false would still be cheap insurance for the exactly-once-resume contract.
Note: I could not build locally —
swift buildfails on a missingFrameworks/GhosttyKit.xcframeworkbinary artifact, which is an environmental/checkout issue unrelated to this diff. The change is self-contained to one file; the PR reports a cleanswift build --arch arm64and 145 passing tests.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Approve — driven by [0 Red, 0 Yellow, 2 Green] findings. Correct, concurrency-clean fix that resolves the beachball without altering kickoff ordering or behavior on error paths.
## Summary - Stop trusting the lagging `ReviewRequest.reviewSessionID` cross-ref and consult `appState.sessions` directly via a new `AppState.existingReviewSession(forPRURL:)` helper. - Layer the check at three sites: auto-review watcher guard, `createReviewSession` early-return backstop, and both Start Review buttons in `ReviewBoardView`. - Extract the PR-URL parser into `Session.parseReviewPR(url:)` so all sites share one parser. ## Why After #405 moved `createReviewSession`'s git work into `Task.detached`, the main actor stays responsive during the ~10s clone window. The IssueTracker watcher can now tick during that window — and because `request.reviewSessionID` is populated lazily on the *next* refresh, the watcher's existing guard sees stale `nil`, enqueues a second kickoff, and `createReviewSession` (with no dedup of its own) creates a duplicate session. The same lag was also why the per-row Start Review button stayed clickable for several seconds after a session had been created. ## Test plan - [x] `arch -arm64 swift test --package-path Packages/CrowCore` — 205 tests pass (includes the new `existingReviewSession` and `parseReviewPR` test files) - [x] `arch -arm64 swift test` (root + every package) — all suites pass except a pre-existing flaky `retryReadinessEmitsTimedOutWhenSentinelMissing` tmux timing test in CrowTerminal that's unrelated to this change - [x] `swift build --arch arm64` clean - [ ] Manual smoke (reviewer): with `autoReviewRepos` configured, open a `crow:auto` PR, confirm exactly one `review-<repo>-<num>` session appears even when the IssueTracker ticks multiple times during the clone; confirm the Start Review button flips to "Go to Session" on the same tick the session appears; force-push the PR to confirm the SHA-advanced re-kick path still works. Closes #406 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
Summary
gh/gitstep, producing the macOS spinning beachball for the duration of the worst step (often a fullgh repo clone).SessionServiceis@MainActor, so even thoughcreateReviewSession()isasync, the oldshell()helper usedProcess.waitUntilExit()— which blocked the main thread instead of yielding.Fix 1 —
shell()truly yieldsReplaced
process.waitUntilExit()withwithCheckedThrowingContinuation+process.terminationHandler, and markedshell()(and a newrunShellAsyncstatic)nonisolated. Nowawait shell(...)actually suspends and frees the main actor while the subprocess runs in the background.Fix 2 —
createReviewSession()runs git/file-prep off-mainExtracted the
gh pr view/gh repo clone/git fetch/checkout/pull/ prompt-write / skill-copy block into a newnonisolated static prepareReviewClone(...)helper invoked viaTask.detached(priority: .userInitiated). The main-actor tail only handles the cheap state mutations and theprepareTerminalcall (still bounded by the existing 2s tmux watchdog).The kickoff serialization queue in
AppDelegate.enqueueReviewKickoffis unchanged — multiple PRs still process in order; theawaits just no longer wedge the UI.Closes #404
Test plan
swift build --arch arm64cleanarch -arm64 swift test --arch arm64— 145 tests passautoReviewWatcherEnabled, push a commit to an open PR, observe the watcher fire a kickoff — UI stays responsive.enqueueReviewKickoff. UI stays responsive; sessions appear in deterministic order (queue serialization preserved).🐦⬛ Generated with Claude Code, orchestrated by Crow