Repository navigation
fix(ios): removing several assistants no longer crashes Access - #89
Conversation
The swipe's Remove access has the destructive role, so the list takes the row out at once and expects the data to lose it in the same action. The app waited for the box first, then removed the row, then read the list again, with the request's section a step later. The collection view and SwiftUI disagreed by a row until the box answered, and the next update failed UIKit's count check: a tester's crash on 17 October 2026 after removing the Grok grants left by four tries. AccessModel.remove hides the row and its requests in the swipe's own change and then tells the box. The answer changes nothing on screen unless the box refuses; then the row comes back with the reason, which stays only while the row does. The list is read once after the last answer, and a read sent before a removal cannot bring its row back. The grant page closes at once the same way. The demo takes -FTWDemoAccess grok and -FTWDemoAccessDelayMs, and AccessRemoveTests swipes the five rows away against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fabfd07574
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| private func withoutRemoved(_ l: AgentList) -> AgentList { | ||
| guard !removed.isEmpty else { return l } | ||
| var l = l | ||
| l.agents.removeAll { removed.contains($0.id) } |
There was a problem hiding this comment.
Stop hiding re-granted assistants forever
Once a revoke succeeds, its ID is added to removed, but that tombstone is never cleared. If the same assistant key/ID is granted again while this AccessModel remains alive—whether from this phone or another owner phone—the box accepts the new grant, yet every subsequent agent.list response deletes its row here. The assistant then has active access that this UI neither displays nor lets the owner revoke; retire tombstones after an authoritative post-revoke list confirms the old row is absent, or otherwise distinguish grant incarnations.
Useful? React with 👍 / 👎.
Fixes the crash a tester hit on 17 October 2026 (TestFlight 0.4.7 (230), iOS 27.0.1) when they removed the unused Grok grants left after four tries to connect Grok. TestFlight 0.4.9 (250) has the same code.
Cause
The crash is
NSInternalInconsistencyExceptionfrom-[UICollectionView _Bug_Detected_In_Client_Of_UICollectionView_Invalid_Number_Of_Items_In_Section:], raised in SwiftUI's batch update of the Access list. No app frames.The Remove access button in each row's swipe actions has the destructive role. On that tap, the list takes the row out of the collection view at once and expects the data to lose it in the same action.
AccessViewinstead started aTaskthat waited for the box's answer, then removed the row, then read the whole list again. Claude's request, and with it the "Waiting for you" section, went in yet another update. Until the box answered, the collection view held one row fewer than SwiftUI's data. The next list update then failed UIKit's count check. The simulator gave the same assertion as the tester's log:Fix
AccessModel.remove(_:)hides the row and its requests (removing) in the same change as the swipe, then sendsagent.revoke. The swipe calls it insidewithAnimation(Theme.spring). One user action, one list change. A row's request and its section leave in that same change.agent.listanswer sent before a removal cannot bring the row back..animation(Theme.spring, value: access.list)stays. After the fix, only reads changelist, never in the same change as a swipe.Same pattern elsewhere
remove. Fixed by the same change.Proof
Repro:
FTWUITests/AccessRemoveTestsagainst the demo box with-FTWDemoAccess grok(Claude with a request waiting, plus four Grok grants, three never used) and-FTWDemoAccessDelayMs(the box answers late). One case uses full swipes; the other swipes, then taps Remove access. Simulator: iPhone 18 Pro, iOS 27.0.An earlier run on main, under heavier load, did not crash, but one Grok row stayed on screen. Its result bundle was lost when I stopped the run, so it is not counted above. On main, the crash came on the third full swipe. The full log of the repro crash is in main-repro-crash.txt. The tester's own log is not in the repo.
On this branch, each case asserts that a row is gone right after its swipe, before the box answers. It then waits 8 s for every answer and the read after the last, and checks that no row and no request came back.
The screenshot taken after every answer had come in is byte-identical to the third one, so the answers changed nothing on screen. All screenshots are on the
claude/access-revoke-crash-shotsbranch.FTWKit (
swift testinappleApp/FTWKit): 266 + 541 tests pass. New tests inAccessFlowTests:severalRemovalsLeaveOneConsistentList: five removals before any answer. Each row and its request leave at once, nothing comes back while the answers arrive, and the box is read once.concurrentRevokesAllSucceed: fiverevokecalls at once all return true. A second removal of a row whose removal is already on its way returns false and sends nothing.aRefusedRemovalComesBackWithTheReason: the box refuses one of four. That row comes back once, with "Your box is busy", and the others stay gone.aLostAnswerEndsWithTheBoxsWord: the box removes the row but its answer is lost. The read after puts the box's word on screen, with no stale failure.removingWhatIsAlreadyGoneIsDone:E_NOT_FOUNDcounts as removed.The app, widgets and UI tests build for the simulator (
xcodebuild build-for-testing -scheme FTW).Not covered
🤖 Generated with Claude Code