Run session deletion off the main actor (closes #250)#251
Conversation
Worktree teardown shells out to git and rm via process.waitUntilExit and synchronous FileManager calls. Doing that on the @mainactor SessionService froze the app — single deletes for a few seconds, batch deletes proportionally longer because BulkDeleteSessionsAlert iterated serially. Push the slow disk work into a Task.detached, expose a per-session isDeletingSession flag for an inline spinner / dimmed row, surface failures via sessionDeletionError so the row keeps a red triangle (and the session itself) until the user retries, and fan batch deletes out as concurrent Tasks so N deletes finish in roughly one delete's time. The CLI delete-session RPC inherits the same path. 🤖 Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: EAD0ACF1-C213-4DE8-8E46-7282960F5694
dgershman
left a comment
There was a problem hiding this comment.
Code & Security Review
Critical Issues
None found.
Security Review
Strengths:
WorktreeCleanupItemisSendable— clean data transfer across actor boundaries with no risk of data races onAppState.- Protected branch guard (
SessionWorktree.isProtectedBranch) is preserved on the background path, preventing accidental deletion ofmain/master. - Manager session (
00000000-...) delete guard is preserved. ShellEnvironment.shared.envis used forProcess.environment, keeping the environment consistent and avoiding leaking userPATHvariations.
Concerns:
- None material. The
runShellSynchelper constructsProcessarguments from trusted internal state (repo paths, branch names) rather than user input, so command injection is not a risk here.
Code Quality
Well done:
- The snapshot-then-detach pattern (
WorktreeCleanupItemvalue type →Task.detached) is the correct way to avoid holding@MainActor-isolated references across anawaitboundary. Clean separation of concerns. - The re-entrancy guard (
guard appState.isDeletingSession[id] != true) prevents double-delete races. performDiskCleanupisnonisolated static— no accidental captures ofselforappState. Correct.- Error auto-clear with the 6-second
Task.sleepis a nice UX pattern, and the[weak appState]capture avoids a retain cycle. - Bulk delete fan-out (
Taskper session,await t.valueto join) is a straightforward improvement over serial iteration.
Minor observations (non-blocking):
-
runShellSyncreads pipes afterwaitUntilExit(SessionService.swift:651-652): callingreadDataToEndOfFile()after the process exits is fine for the short outputs expected here (git status messages), but for very large stdout/stderr this can technically deadlock if the pipe buffer fills before the process exits. Not a practical concern forgit worktree remove/git branch -D/git worktree prune— mentioning only for awareness. -
Bulk delete error visibility (
DeleteSessionAlert.swift:157-163): when individual deletes in a bulk operation fail, errors areNSLog'd but the per-session error indicators will show on the rows (viasessionDeletionError). TheonCompletion()callback (which clears the selection) fires after all tasks join — if some sessions failed and remained, they'll be deselected but show the red triangle. This is reasonable behavior. -
onCompletion()removedMainActor.runwrapper (DeleteSessionAlert.swift:165): the outerTask { }inherits the main actor from the@MainActor-isolatedbodycontext, so after thefor t in tasks { await t.value }loop resumes, we're back on the main actor. This is correct.
Summary Table
| Priority | Issue |
|---|---|
| 🟢 | readDataToEndOfFile after waitUntilExit — fine for these short git commands |
| 🟢 | Consider whether bulk-delete selection clearing after partial failure is the desired UX |
Recommendation: Approve — the async-delete architecture is sound, the concurrency boundaries are correct, the UI feedback (spinner, error indicator, disabled state) is thorough, and there are no security concerns. Ship it.
Summary
SessionService.deleteSessionnow snapshots its work on the main actor, runsgit worktree remove/branch -D/worktree pruneand the residualFileManager.removeIteminsideTask.detached(priority: .utility), then finalizes state back on the main actor — so the UI repaints during the slow part instead of beach-balling.AppState.isDeletingSessionandAppState.sessionDeletionError. While a delete is in flight the row dims and shows aProgressView; on success the row goes away; on a fatal cleanup failure the session stays in place with a redexclamationmark.triangle.fill(with hover-text describing the cause) for ~6 s so the user can retry. Per-row and detail-view delete actions, plus the "Mark as In Review" / "Mark as Completed" context-menu items, are disabled while a delete is in flight.BulkDeleteSessionsAlertnow fans each delete out as its ownTask(joined viaawait task.value) instead of iterating serially, so multi-select delete of N sessions finishes in roughly the time of a single delete.delete-sessionRPC inherits the same path — no separate code path.Test plan
make buildis cleanswift test(root) andswift testinPackages/CrowUIboth pass (existing 63 + 22 tests)rm -rfa worktree directory before pressing delete sogit worktree removefails): row stays, red triangle appears with hover-text, NSLog matches; retrying after the auto-clear succeedscrow delete-session --session <uuid>still completes without UI freezegit worktree listand~/Library/Application Support/Crow/store.jsonshow no leftover state for the deleted IDsCloses #250
🤖 Generated with Claude Code