Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/ios/CovenCave/CovenCave/CovenCaveApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ struct CovenCaveApp: App {
// A state that *says* connected can be stale after a suspension,
// so it gets one cheap validation probe instead of blind trust.
.onChange(of: scenePhase) { _, phase in
// Leaving the foreground: flush any debounced thread
// persistence synchronously so an in-flight write isn't
// lost if the app is suspended or terminated.
if phase != .active { app.flushThreads() }
guard phase == .active, app.connection != nil else { return }
if app.connectionState != .connected,
app.connectionState != .checking {
Expand Down
35 changes: 30 additions & 5 deletions apps/ios/CovenCave/CovenCave/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1490,13 +1490,38 @@ final class AppModel {
return dir.appendingPathComponent("cave-threads.json")
}

/// Pending debounced thread-persist flush. Not observable state.
@ObservationIgnored private var persistThreadsTask: Task<Void, Never>?

func persistThreads() {
// Debounce: many call sites (message send/receive, edits, archive,
// reorder) fire in quick bursts. Encoding every thread + message to
// JSON and writing to disk on each call — on the main thread — was a
// needless hitch. Coalesce bursts into one write shortly after the last
// change, and do the encode + write off the main thread.
persistThreadsTask?.cancel()
persistThreadsTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
self?.flushThreads()
}
}

/// 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)
Comment on lines +1510 to 1516

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

do {
let data = try JSONEncoder().encode(snapshots)
try data.write(to: threadsFileURL, options: .atomic)
} catch {
// Non-fatal: persistence is best-effort.
let url = threadsFileURL
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.
}
}
Comment on lines +1518 to 1525

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

}

Expand Down
Loading