Merge nucleic/amber-thistle-dingo-urcn into dev
This commit is contained in:
@@ -93,6 +93,11 @@ final class HostConnection {
|
||||
/// (the one callback that *shrinks* the on-screen transcript). Carries the sessionID so
|
||||
/// RemoteStore can confirm it's still the open one before mutating.
|
||||
var openReverted: (SessionID, UInt64) -> Void = { _, _ in }
|
||||
/// Any session was reverted on the owner (open or not) — fired alongside the truncation
|
||||
/// handling so RemoteStore can record the owner's post-revert epoch as applied. That
|
||||
/// record is what stops the summary-level missed-revert heal from re-dropping a cache
|
||||
/// this live message already truncated precisely.
|
||||
var transcriptReverted: (TranscriptReverted) -> Void = { _ in }
|
||||
/// A background transcript prefetch finished — everything it fetched (empty when the
|
||||
/// host had nothing / the fetch died with the connection). RemoteStore merges it into
|
||||
/// the offline cache and starts the next queued prefetch either way.
|
||||
@@ -794,6 +799,9 @@ final class HostConnection {
|
||||
break
|
||||
case .transcriptReverted(let reverted):
|
||||
// The owner reverted/undid this chat — the transcript was truncated to `throughSeq`.
|
||||
// Tell RemoteStore first (it records the applied revert epoch so the summary-level
|
||||
// missed-revert heal knows this device converged via this precise path).
|
||||
callbacks.transcriptReverted(reverted)
|
||||
if reverted.sessionID == openSessionID {
|
||||
// On screen: drop our cursor/dedup state for the dropped seqs (so a warm-resubscribe
|
||||
// replays from the new tip, not the stale one) and have RemoteStore shrink the live
|
||||
|
||||
@@ -1498,6 +1498,18 @@ final class RemoteStore: ObservableObject {
|
||||
self.persistOpenTranscript()
|
||||
}
|
||||
}
|
||||
cb.transcriptReverted = { [weak self] reverted in
|
||||
guard let self else { return }
|
||||
// The transcript shrank — clear the prefetch watermark so the cache re-warms once
|
||||
// the owner regrows, instead of waiting for the tip to pass its pre-revert high.
|
||||
self.prefetchedSeq[reverted.sessionID] = nil
|
||||
guard let epoch = reverted.revertEpoch else { return }
|
||||
// The live revert path (openReverted / HostConnection's cache truncate) converges
|
||||
// this device precisely. Record the epoch it converged to so the summary-level
|
||||
// missed-revert heal (`healMissedReverts`) recognizes the bumped epoch in the next
|
||||
// session list as already applied instead of re-dropping the whole cache.
|
||||
self.appliedRevertEpochs[reverted.sessionID] = epoch
|
||||
}
|
||||
cb.transcriptPrefetched = { [weak self] sessionID, events in
|
||||
guard let self else { return }
|
||||
self.prefetchInFlight = nil
|
||||
@@ -1644,6 +1656,13 @@ final class RemoteStore: ObservableObject {
|
||||
let live = aggregatedSessions()
|
||||
if !live.isEmpty {
|
||||
if sessions != live {
|
||||
// Before adopting the fresh list, compare it against the last summaries we held
|
||||
// (seeded from disk on a cold launch): a session whose revert epoch moved while
|
||||
// we weren't receiving the live `.transcriptReverted` — offline across a revert —
|
||||
// has a cache that may hold pre-revert content at reused seqs, which every merge
|
||||
// path here is too grow-only to ever fix. Heal it now, before the stale summaries
|
||||
// are overwritten.
|
||||
healMissedReverts(previous: cachedSummaries, incoming: live)
|
||||
sessions = live
|
||||
cachedSummaries = live
|
||||
persistSummaries(live)
|
||||
@@ -1720,6 +1739,66 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
// MARK: - Offline cache (SessionCache)
|
||||
|
||||
/// Revert epochs already applied via the live `.transcriptReverted` path, per session — so
|
||||
/// `healMissedReverts` can tell a revert this device truncated precisely (connected) from one
|
||||
/// it slept through (offline), and only nukes the cache for the latter. In-memory only: a
|
||||
/// relaunch that lost this map at worst re-drops a cache the prefetcher re-warms.
|
||||
private var appliedRevertEpochs: [SessionID: UInt64] = [:]
|
||||
|
||||
/// Detect reverts this device missed and drop what they invalidated. A revert on the owner
|
||||
/// resets the transcript's seq counter, so post-revert turns reuse dropped seqs with
|
||||
/// *different* content; every merge here (seed, snapshot, live, backfill, prefetch) dedupes
|
||||
/// on seq and only grows — a phone offline across the revert would keep the stale events
|
||||
/// forever. The owner's `SessionSummary.revertEpoch` moving against the summary we last held
|
||||
/// is the tip-independent signal: drop the cached transcript, requalify the prefetch, and —
|
||||
/// if the session is on screen — rebuild the open transcript from the host cold. A legacy
|
||||
/// host (no epoch) still gets the tip-regression heal: a `lastSeq` below what we held means
|
||||
/// at least the tail is gone, so shrink the cache to it.
|
||||
private func healMissedReverts(previous: [WireSessionSummary], incoming: [WireSessionSummary]) {
|
||||
guard !previous.isEmpty else { return }
|
||||
let prevByID = Dictionary(previous.map { ($0.sessionID, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
for summary in incoming {
|
||||
guard let prev = prevByID[summary.sessionID] else { continue }
|
||||
if let epoch = summary.revertEpoch,
|
||||
appliedRevertEpochs[summary.sessionID] != epoch,
|
||||
prev.revertEpoch.map({ $0 != epoch }) ?? (epoch > 0) {
|
||||
// Missed revert (or a pre-epoch cached summary we can't vouch for on an
|
||||
// ever-reverted session): the cache may hold another generation's content.
|
||||
appliedRevertEpochs[summary.sessionID] = epoch
|
||||
SessionCache.removeEvents(for: summary.sessionID)
|
||||
prefetchedSeq[summary.sessionID] = nil
|
||||
if summary.sessionID == openSessionID {
|
||||
resubscribeOpenSessionCold(summary.sessionID)
|
||||
}
|
||||
} else if summary.revertEpoch == nil, summary.lastSeq < prev.lastSeq {
|
||||
// Legacy host: no epoch to compare, but the tip regressed below what we held —
|
||||
// drop at least the now-dropped tail (reused seqs below the new tip are not
|
||||
// detectable without epochs).
|
||||
SessionCache.truncateEvents(for: summary.sessionID, throughSeq: summary.lastSeq)
|
||||
prefetchedSeq[summary.sessionID] = nil
|
||||
if summary.sessionID == openSessionID {
|
||||
drainPendingOpenEvents()
|
||||
openEvents = openEvents.filter { $0.seq <= summary.lastSeq }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the open session's transcript from the host, cold — after a missed revert made
|
||||
/// everything we hold for it (screen, buffers, connection cursors, cache) untrustworthy.
|
||||
/// Mirrors `open()`'s reset without touching navigation: clear the view state, reset the
|
||||
/// owning connection's per-session cursor/dedup/fetch state (the `openSessionID` didSet),
|
||||
/// then re-subscribe with no cursor and re-pull the full history.
|
||||
private func resubscribeOpenSessionCold(_ sessionID: SessionID) {
|
||||
discardPendingOpenEvents()
|
||||
openEvents = []
|
||||
guard let conn = connection(owningSession: sessionID) else { return }
|
||||
conn.openSessionID = nil
|
||||
conn.openSessionID = sessionID
|
||||
conn.send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
conn.fetchFullTranscript(sessionID)
|
||||
}
|
||||
|
||||
/// Debounced write of the live session list to disk, so a read-only history survives a
|
||||
/// disconnect / relaunch.
|
||||
private func persistSummaries(_ list: [WireSessionSummary]) {
|
||||
@@ -1789,13 +1868,22 @@ final class RemoteStore: ObservableObject {
|
||||
Task { [weak self] in
|
||||
// Pull only the gap beyond what's cached; a cold session is bounded to the same
|
||||
// tail window the cache would keep anyway.
|
||||
let cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0
|
||||
var cachedLast = await SessionCache.loadEvents(id).last?.seq ?? 0
|
||||
guard let self else { return }
|
||||
if cachedLast >= summary.lastSeq {
|
||||
if cachedLast == summary.lastSeq {
|
||||
self.prefetchInFlight = nil // cache already current
|
||||
self.pumpTranscriptPrefetch()
|
||||
return
|
||||
}
|
||||
if cachedLast > summary.lastSeq {
|
||||
// The host's tip is *below* our cache — a revert we missed. This is not "already
|
||||
// current": the host reuses the dropped seqs with different content, so the whole
|
||||
// cached copy is suspect (a legacy host without revert epochs never triggers the
|
||||
// epoch heal, and reading "ahead of the host" as current was what let the stale
|
||||
// copy live forever). Drop it and re-pull from scratch.
|
||||
SessionCache.removeEvents(for: id)
|
||||
cachedLast = 0
|
||||
}
|
||||
let coldFloor = summary.lastSeq > UInt64(SessionCache.eventLimit)
|
||||
? summary.lastSeq - UInt64(SessionCache.eventLimit) : 0
|
||||
let afterSeq = cachedLast > 0 ? cachedLast : coldFloor
|
||||
|
||||
Reference in New Issue
Block a user