crossmate

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

commit afd967245f8b5ff626d547ea218180f364f39f16
parent 4ce3988c737c98c74b88adf13db65886bb65c215
Author: Michael Camilleri <[email protected]>
Date:   Sun,  7 Jun 2026 00:36:34 +0900

Retain diagnostics events by time, not count

EventLog was a fixed 1000-entry ring trimmed by count. During an active
co-solve the log can churn at ~5 events/sec which would mean that a
buffer would only hold ~3 minutes.

This commit switches to a 60-minute time window as the governing limit
so that the buffer always reaches back a known wall-clock span
regardless of how chatty the session is. The entry count cap stays only
as a runaway guard (raised to 30k) so a tight error-retry loop can't
grow the buffer without bound inside the window.

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

Diffstat:
MCrossmate/Services/DebuggingMonitors.swift | 32++++++++++++++++++++++++++++++--
1 file changed, 30 insertions(+), 2 deletions(-)

diff --git a/Crossmate/Services/DebuggingMonitors.swift b/Crossmate/Services/DebuggingMonitors.swift @@ -189,14 +189,42 @@ struct EventLogEntry: Identifiable, Sendable { final class EventLog { private(set) var entries: [EventLogEntry] = [] - private let maxEntries = 1000 + /// How far back the log reaches. A time window — rather than a fixed entry + /// count — guarantees the buffer always spans a known wall-clock duration + /// 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 + + /// 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 + /// above an hour of normal+intense traffic (~18k entries) so the time + /// window stays the governing limit in practice; this only trips on a fault. + private let maxEntries = 30_000 func note(_ message: String, level: String = "info") { append(level: level, message) } fileprivate func append(level: String, _ message: String) { - entries.append(EventLogEntry(timestamp: Date(), level: level, message: message)) + let now = Date() + entries.append(EventLogEntry(timestamp: now, level: level, message: message)) + prune(now: now) + } + + /// Drops entries older than the retention window, then enforces the count + /// ceiling. Entries are appended in timestamp order, so the expired ones are + /// always a prefix — `firstIndex` stops at the first fresh entry, making the + /// scan proportional to how many just aged out (typically zero or one per + /// append), not to the buffer size. The entry just appended carries `now`, + /// so at least one entry always survives the cutoff. + private func prune(now: Date) { + let cutoff = now.addingTimeInterval(-retention) + if let firstFresh = entries.firstIndex(where: { $0.timestamp >= cutoff }), firstFresh > 0 { + entries.removeFirst(firstFresh) + } if entries.count > maxEntries { entries.removeFirst(entries.count - maxEntries) }