Skip to content

fix(ios): removing several assistants no longer crashes Access - #89

Merged
frahlg merged 1 commit into
mainfrom
claude/access-revoke-crash
Oct 18, 2026
Merged

frahlg merged 1 commit into
mainfrom
claude/access-revoke-crash

Conversation

@frahlg

@frahlg frahlg commented Oct 18, 2026

Copy link
Copy Markdown
Member

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 NSInternalInconsistencyException from -[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. AccessView instead started a Task that 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:

Invalid update: invalid number of items in section 1. The number of items contained in an existing section after the update (5) must be equal to the number of items contained in that section before the update (4), plus or minus the number of items inserted or deleted from that section (0 inserted, 0 deleted)

Fix

  • AccessModel.remove(_:) hides the row and its requests (removing) in the same change as the swipe, then sends agent.revoke. The swipe calls it inside withAnimation(Theme.spring). One user action, one list change. A row's request and its section leave in that same change.
  • The box's answer changes nothing on screen unless the box refuses. Then the row comes back and the reason shows under the list. A failure stays on screen only while its row does: when an answer is lost but the box did remove the row, the next read takes the row away and no failure is left.
  • Each removal goes to the box at once, on its own. The list is read once, after the last answer, rather than once per removal. An agent.list answer sent before a removal cannot bring the row back.
  • Remove access on a grant's own page now closes the page at once, the same way.
  • .animation(Theme.spring, value: access.list) stays. After the fix, only reads change list, never in the same change as a swipe.

Same pattern elsewhere

  • Sourceful support rows: same swipe, same remove. Fixed by the same change.
  • Requests: answered one at a time on their own page, with no swipe. A removed assistant's requests now leave with it, in one change.
  • Cars in Settings (iPhone and watch: the car first, from the box's car list (car step 4) #85): no swipe. A car is removed from its own page behind a confirmation, one at a time. Reads are coalesced, and a token drops stale ones. No change needed.

Proof

Repro: FTWUITests/AccessRemoveTests against 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.

Build Delay Full swipes Swipe and tap
main 9ca87ec (demo switch and the test's first version only) 600 ms app crashed on the 3rd swipe, assertion above rows removed, no crash
this branch fabfd07 600 ms pass pass
this branch fabfd07 2500 ms (removals overlap) pass pass

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.

Before, main: the demo home Before, main: Claude's row is gone, its request a step behind After: right after the last swipe, the box has not answered yet

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-shots branch.

FTWKit (swift test in appleApp/FTWKit): 266 + 541 tests pass. New tests in AccessFlowTests:

  • 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: five revoke calls 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_FOUND counts as removed.

The app, widgets and UI tests build for the simulator (xcodebuild build-for-testing -scheme FTW).

Not covered

  • Not tried on a phone or against a real box.
  • The four Grok rows themselves, and why Grok took four tries, are for a separate session.
  • XCUITest on this Mac sometimes waited up to 16 minutes for the app to go idle. The Mac's load average was 200 to 600, from simulators shared with other sessions. A CPU log showed the app using about 1 s of CPU through such a wait, so the stall was the Mac's, not the app's.

🤖 Generated with Claude Code

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-18T04:14:32.746909Z fabfd07 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@frahlg
frahlg merged commit 675c3f3 into main Oct 18, 2026
1 check 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