perf(ios): debounce thread persistence off the main thread#3456
Conversation
persistThreads() encoded every thread + message to JSON and wrote the file on the main thread, and it is called from ~17 sites that fire in quick bursts (send, receive, edit, archive, reorder). On a large history each call was a main-thread hitch. Debounce the persist into a single write ~400ms after the last change, and do the encode + atomic write off-main: snapshot the threads on the main actor (cheap value-type map) and hand the Sendable snapshots to a detached utility task. Add flushThreads() for a synchronous flush and call it when the scene leaves the foreground so a pending write survives suspension or termination. Verified: xcodebuild build (scheme CovenCave, iOS Simulator) succeeds.
There was a problem hiding this comment.
Pull request overview
This PR improves iOS responsiveness by debouncing chat thread persistence and moving JSON encoding + disk writes off the main thread, aiming to eliminate UI hitches caused by frequent synchronous saves during bursty thread updates.
Changes:
- Debounced
persistThreads()so bursty callers coalesce into a single persistence operation after ~400ms. - Snapshotted thread state on the main actor, then encoded + atomically written on a detached utility task.
- Added a foreground-exit hook to flush pending persistence when the scene phase leaves
.active.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| apps/ios/CovenCave/CovenCave/State/AppModel.swift | Adds debounced persistence machinery and off-main encoding/write path for thread snapshots. |
| apps/ios/CovenCave/CovenCave/CovenCaveApp.swift | Triggers a persistence flush when the app leaves the foreground to reduce risk of losing a pending debounced write. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Task.detached(priority: .utility) { | ||
| do { | ||
| let data = try JSONEncoder().encode(snapshots) | ||
| try data.write(to: url, options: .atomic) | ||
| } catch { | ||
| // Non-fatal: persistence is best-effort. | ||
| } | ||
| } |
There was a problem hiding this comment.
Addressed in 38381fb: normal writes now enter one serial persistence queue, so writes run in snapshot order and a stale write cannot finish after a newer queued snapshot. The commit is published at romgenie@38381fb; it needs cherry-picking because I do not have permission to push this PR branch.
| /// Snapshot on the main actor (cheap value-type map), then encode + write | ||
| /// off-main. Call directly when a synchronous flush is required (e.g. app | ||
| /// moving to the background). | ||
| func flushThreads() { | ||
| persistThreadsTask?.cancel() | ||
| persistThreadsTask = nil | ||
| let snapshots = threads.map(\.snapshot) |
There was a problem hiding this comment.
Addressed in 38381fb: the scene-phase path now synchronously drains the serial background persistence queue and writes the final snapshot before returning. Normal debounced saves remain asynchronous. The commit is published at romgenie@38381fb; it needs cherry-picking because I do not have permission to push this PR branch.
|
I found a persistence correctness gap: I prepared 38381fb8, which serializes normal background writes and makes the lifecycle flush wait for that queue before returning. I cannot update this PR branch: GitHub rejected the push for |
Summary
Third PR in the iOS performance pass. Removes main-thread hitches from thread persistence.
Root cause
AppModel.persistThreads()encoded every thread + every message to JSON and wrote the file synchronously on the main thread, and it's called from ~17 sites that fire in quick bursts (send, receive, edit, archive, reorder). On a large history each call was a visible main-thread hitch, and bursts multiplied it.Fix
persistThreads()now schedules a single write ~400ms after the last change, cancelling any pending one — bursts collapse to one write.SendableThreadSnapshots), then encode + atomic-write in a detached utility task.flushThreads()for a synchronous flush, called fromonChange(of: scenePhase)when the scene leaves the foreground, so a pending debounced write survives suspension/termination.All 17 existing
persistThreads()callers are unchanged — the debounce is transparent.Verification
xcodebuild build(schemeCovenCave, iOS Simulator) → BUILD SUCCEEDED.Blast radius
Small — 2 files. Same on-disk format and file URL; only the timing/threading of the write changed.
persistThreadsTaskis@ObservationIgnored(internal machinery, not UI state).Notes
Stacks on the earlier chat-render (#3454) and stream-coalescing (#3455) PRs. CI does not build the Swift app; local
xcodebuildis the iOS gate.