Files
bitchat/bitchat/Services/NostrProcessedEventStore.swift
T
81c45e9649 Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix (#1398)
* Reconnect hygiene: paced acks, persistent gift-wrap dedup, self-fragment sync fix

Remaining findings from the July 7 locked-phone test sessions, all rooted
in reconnect/relaunch behavior:

- All Nostr acks (READ and DELIVERED, direct and geohash) now flow through
  the paced queue that previously only throttled direct READ receipts.
  Reconnect redelivery produced 8 DELIVERED acks in under a second, which
  damus rejects ("noting too much").

- Processed gift-wrap event IDs persist across launches
  (NostrProcessedEventStore, wired through MessageDeduplicationService,
  debounced writes, wiped on the existing clear/panic paths). NIP-59
  randomizes gift-wrap timestamps, so the 24h-lookback DM subscriptions
  redeliver the same events every launch; without a cross-launch record
  each relaunch reprocessed old PMs and acks — the re-ack bursts and the
  "delivered ack for unknown mid" warnings (now debug: a stale ack is
  expected occasionally and not actionable).

- Own fragments handed back by sync replay (the deliberate RSR ttl=0
  restore path) now re-enter the gossip sync store before the self-drop.
  The fragment store is not archived, so after a relaunch our sync filter
  did not cover our own fragments and peers re-offered them every 30s
  round indefinitely; recording them stops the redelivery after one round
  while keeping assembly skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address Codex review on #1398: shared ack pacer, transient clears keep disk record

- Geohash acks are sent through short-lived NostrTransport instances
  (makeGeohashNostrTransport creates one per ack), so the per-instance ack
  queue never paced a burst. Acks now flow through a pacer shared across
  instances: Dependencies.live wires the process-wide sharedAckPacer, and
  the default Dependencies init builds an isolated pacer from the same
  injected scheduleAfter so tests keep stepping the throttle manually.

- clearNostrCaches() runs on every geohash channel switch, so it no longer
  wipes the persisted gift-wrap record (that stays on the clearAll/panic
  path). Persistence is now append-merge instead of snapshot-overwrite —
  serialized on the store's IO queue — so a transient in-memory clear
  between debounced flushes can't shrink the on-disk record either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:08:10 +02:00

107 lines
3.8 KiB
Swift

//
// NostrProcessedEventStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
/// and relays redeliver the same events on every launch — without a
/// cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
///
/// Contents are event IDs already visible to every relay, so
/// until-first-unlock file protection is the right at-rest posture — the
/// file must also load during a locked-background restoration relaunch.
/// Wiped on panic via the dedup service's clear paths.
final class NostrProcessedEventStore {
private let fileURL: URL?
// All file access is serialized here: appends are read-modify-write, and
// overlapping debounced flushes would otherwise race and drop IDs.
private let ioQueue = DispatchQueue(label: "chat.bitchat.nostr-processed-events", qos: .utility)
init(fileURL: URL? = nil) {
self.fileURL = fileURL ?? Self.defaultFileURL()
}
/// Processed event IDs, oldest first (insertion order).
func load() -> [String] {
ioQueue.sync { loadLocked() }
}
/// Merge new IDs onto the persisted record, oldest-first, trimming from
/// the front past `cap`. Append-merge (not snapshot-overwrite) so the
/// in-memory cache being cleared transiently (channel switches) can
/// never shrink the on-disk record.
func append(_ newIDs: [String], cap: Int) {
guard !newIDs.isEmpty else { return }
ioQueue.async { [self] in
var merged = loadLocked()
var known = Set(merged)
for id in newIDs where !known.contains(id) {
merged.append(id)
known.insert(id)
}
if merged.count > cap {
merged.removeFirst(merged.count - cap)
}
saveLocked(merged)
}
}
func wipe() {
ioQueue.async { [self] in
guard let fileURL else { return }
try? FileManager.default.removeItem(at: fileURL)
}
}
private func loadLocked() -> [String] {
guard let fileURL,
let data = try? Data(contentsOf: fileURL),
let ids = try? JSONDecoder().decode([String].self, from: data) else {
return []
}
return ids
}
private func saveLocked(_ eventIDs: [String]) {
guard let fileURL else { return }
guard !eventIDs.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
do {
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(eventIDs)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist processed Nostr events: \(error)", category: .session)
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("nostr", isDirectory: true)
.appendingPathComponent("processed-events.json")
}
}