crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit e23dfeb33591059f139f4c566ebd497ebafde29f
parent aa5208b02e64fb1f8568a3e6b6b2da1191e872cd
Author: Michael Camilleri <[email protected]>
Date:   Tue,  9 Jun 2026 11:07:02 +0900

Persist the diagnostics event log across launches

The in-app EventLog held breadcrumbs in memory under a one-hour time
window, so it was emptied on every termination and, even when alive,
forgot anything older than an hour. That window suits a live co-solve
but loses one important case: collecting a collaborator's log hours
after their session, such as when their device has slept or relaunched
overnight and the operational events that describe what the collaborator
did -- push(play)/push(pause) sends, sync, errors -- exist only in this
in-memory log, not the durable received-notification receipt buffer.

This commit backs the log with EventLogStore, an actor that owns a
single atomically-rewritten snapshot in Application Support.
loadPersisted hydrates the in-memory view on launch before live
breadcrumbs flow, merging by timestamp so a startup note that races
ahead is not lost; a debounced flush mirrors live appends back to disk
(one write a couple of seconds after a burst, not one per breadcrumb),
and a forced flush on backgrounding -- under a UIApplication assertion
alongside the moves flush -- keeps the tail the timer has not written
yet. A hydrated gate ensures no flush can clobber the file with a
pre-load snapshot.

With the log now durable, the retention window goes from one hour to
twenty-four: long enough to span an evening session plus a quiet night
before someone collects it, while staying cheap on an idle device (a
quiet night writes a handful of rows). The 30k-entry ceiling still
governs the pathological runaway case.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Services/AppServices.swift | 9+++++++++
MCrossmate/Services/DebuggingMonitors.swift | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 97 insertions(+), 5 deletions(-)

diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -368,6 +368,12 @@ final class AppServices { guard !started else { return } started = true + // Hydrate the persisted diagnostics history before live breadcrumbs + // flow, so a log collected this morning still carries last night's + // session. Ordering against startup notes is by timestamp, so a note + // that races ahead of this isn't lost. + await eventLog.loadPersisted() + nytAuth.loadStoredSession() driveMonitor.start() @@ -781,6 +787,9 @@ final class AppServices { ensureInBackground("moves-flush") { [weak self] in await self?.movesUpdater.flush() } + ensureInBackground("event-log-flush") { [weak self] in + await self?.eventLog.flush() + } } /// Completion fan-out, delivered through the push worker. Win sets diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -174,16 +174,53 @@ final class PerformanceMonitor { } } -struct EventLogEntry: Identifiable, Sendable { - let id = UUID() +struct EventLogEntry: Identifiable, Sendable, Codable { + var id = UUID() let timestamp: Date let level: String let message: String } +/// Off-main durable backing for `EventLog`. Owns a single atomically-rewritten +/// snapshot file in Application Support — chosen over Caches, which iOS can +/// purge under storage pressure, exactly when a tester needs the log most. +/// The file survives termination, so a collaborator can still share their +/// diagnostics hours after the activity, long after their app was suspended. +actor EventLogStore { + private let fileURL: URL + + init(filename: String = "event-log.json") { + let dir = FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.fileURL = dir.appendingPathComponent(filename) + } + + func load() -> [EventLogEntry] { + guard let data = try? Data(contentsOf: fileURL), + let entries = try? JSONDecoder().decode([EventLogEntry].self, from: data) + else { return [] } + return entries + } + + /// Rewrites the file from the already-pruned in-memory snapshot. Called + /// coalesced (debounced after a burst, and on backgrounding) rather than + /// per-append, so the O(n) encode stays rare even during an intense + /// ~5/sec co-solve. + func persist(_ entries: [EventLogEntry]) { + guard let data = try? JSONEncoder().encode(entries) else { return } + try? data.write(to: fileURL, options: .atomic) + } +} + /// Generic in-app event log. Anything that wants to leave a breadcrumb the /// user can read in the diagnostics screen calls `note(_:)`. SyncMonitor and /// other producers compose this — they don't subclass it. +/// +/// Backed by `EventLogStore` so the buffer outlives the process: `loadPersisted` +/// hydrates the in-memory view on launch, and a debounced flush mirrors live +/// breadcrumbs back to disk. That durability is what lets a quiet-overnight +/// device still surface a collaborator's hours-old session the next morning. @MainActor @Observable final class EventLog { @@ -194,9 +231,10 @@ final class EventLog { /// regardless of how chatty the session is. During an intense co-solve the /// log churns at ~5 events/sec, so a count cap of a few thousand only holds /// a couple of minutes and evicts the start of the session before anyone - /// can grab it. An hour comfortably covers a full shared solve plus its - /// opening minutes. - private let retention: TimeInterval = 60 * 60 + /// can grab it. A day comfortably spans a collaborator's evening session + /// plus a quiet night before someone collects the log in the morning, while + /// staying cheap on an idle device (a quiet night writes a handful of rows). + private let retention: TimeInterval = 60 * 60 * 24 /// Hard ceiling so a runaway producer (e.g. a tight error-retry loop) can't /// grow the buffer without bound inside the retention window. Sized well @@ -204,6 +242,29 @@ final class EventLog { /// window stays the governing limit in practice; this only trips on a fault. private let maxEntries = 30_000 + private let store: EventLogStore + + /// Until the on-disk history has been merged in, appends must not flush: + /// a flush before `loadPersisted` would clobber the file with a partial, + /// pre-hydration snapshot and lose the very overnight history we're after. + private var hydrated = false + private var flushTask: Task<Void, Never>? + + init(store: EventLogStore = EventLogStore()) { + self.store = store + } + + /// Merges the persisted history into the in-memory view. Call once, early + /// in launch. Any breadcrumbs noted before hydration are kept and ordered + /// in by timestamp, so nothing recorded during startup is lost. + func loadPersisted() async { + let persisted = await store.load() + entries = (persisted + entries).sorted { $0.timestamp < $1.timestamp } + prune(now: Date()) + hydrated = true + scheduleFlush() + } + func note(_ message: String, level: String = "info") { append(level: level, message) } @@ -212,6 +273,28 @@ final class EventLog { let now = Date() entries.append(EventLogEntry(timestamp: now, level: level, message: message)) prune(now: now) + scheduleFlush() + } + + /// Coalesce writes: one persist a couple of seconds after the last append, + /// not one per breadcrumb. A fresh append cancels the pending write and + /// re-arms it, so a steady burst collapses into a single trailing flush. + private func scheduleFlush() { + guard hydrated else { return } + flushTask?.cancel() + flushTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled, let self else { return } + await self.store.persist(self.entries) + } + } + + /// Persist immediately, bypassing the debounce. Call on backgrounding so an + /// imminent suspend keeps the tail that the timer hasn't written yet. + func flush() async { + guard hydrated else { return } + flushTask?.cancel() + await store.persist(entries) } /// Drops entries older than the retention window, then enforces the count