Keep visited transcripts on the phone
Reopening a session downloaded the conversation again, every time, over the tunnel. It now draws from a copy of what the server has already sent and asks for one event to check that copy is still current. Per session, under cacheDir, the server's own event lines in chunks named for the range they cover -- so a coalesced page, whose lines do not say what they cover, still records it. Only the contiguous run ending at the newest chunk is served; a gap is closed by paging through it, bounded by `after` on /transcript so the page stops where the phone's copy starts and can therefore be kept. Nothing is derived and stored: rows are a rendering, and a cache of them would need throwing away on every change to the fold. Nothing here is load-bearing. Missing, evicted, damaged or unwritable all degrade to the cold open this screen did before, and the check before the stream resumes -- one request, one event -- is what stops a replaced or truncated file being spliced onto a copy of a different conversation. What that check cannot see, a line changed mid-file with the tail intact, is what Reload in session settings is for. Measured on the emulator against ui-sandbox, on a 505-event session: reopening it costs one request for one event, including scrolling the whole conversation back; a cold open is two requests and 100 events. A reset after falling 300 behind fetched the gap as four coalesced rows rather than re-fetching 104 events and discarding them. Every chunk was checked line by line against what the server says for the range its name claims, across the reset and the gap-fill. transcript-bench.sh, same viewport content and gestures, before and after: p50 16.9ms both, p90 25.6 -> 23.2ms, p99 33.5 -> 36.7ms, and the transcript's own draw accounting 0.33ms -> 0.32ms with place 0.31ms either way. Within the emulator's noise, which is what a cache must be: it changes what is fetched, not what is drawn. Building it also found that the server handed out the same transcript line two different ways. serde_json's default float parser is not correctly rounded, so a ts written as ...0757 came back from /transcript as ...0755 while the SSE stream sent the original -- invisible on screen, since a ts is drawn as a relative time, and visible here only because the cache compares a line it holds against the server's answer. Fixed with float_roundtrip, with a test that fails the moment it is dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8881a40919
commit
a802522039
17 files changed
+2140
-74
No files matched your search
@@ -81,7 +81,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -324,7 +323,27 @@ fun SessionScreen(
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
// The resume cursor, written from the stream's IO thread.
|
||||
val lastSeq = remember { AtomicLong(0) }
|
||||
val activeStream = remember { AtomicReference<EventStream?>(null) }
|
||||
// Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It
|
||||
// keys everything that describes one visit to this session: the source below, the opening
|
||||
// effect, the stream, and the anchor being put back. See TRANSCRIPT_CACHE.md's decision 8.
|
||||
var epoch by remember(summary.id) { mutableIntStateOf(0) }
|
||||
// This server's cached transcripts, and this session's half of them. The cache is per server
|
||||
// because two servers can hold a session with the same id; the source is per visit because
|
||||
// Reload throws away what it was reading from.
|
||||
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
|
||||
val source =
|
||||
remember(summary.id, epoch) {
|
||||
TranscriptSource(settings, summary.id, cache.session(summary.id))
|
||||
}
|
||||
// Whether the cached tail has been shown to still be the server's own line. Nothing is
|
||||
// resumed from a cached cursor until it has -- see [TranscriptSource.probe] -- and a probe
|
||||
// that could not be made leaves this false for the stream loop to try again.
|
||||
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) }
|
||||
// Whether the opening effect is still settling that question. It draws the cached rows and
|
||||
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
|
||||
// below has to wait for this rather than for `ready`, or it asks the very same question a
|
||||
// second time and races the answer.
|
||||
var probing by remember(summary.id, epoch) { mutableStateOf(true) }
|
||||
// The oldest sequence number loaded, and whether there is more behind
|
||||
// it. Paging backwards is what keeps opening a long session cheap: the
|
||||
// screen starts with the end of the conversation and fetches earlier
|
||||
@@ -333,12 +352,12 @@ fun SessionScreen(
|
||||
// Where this session was last being read, from this device's own store. Read once, because
|
||||
// it is the question "where did I leave off" and the answer stops being interesting the
|
||||
// moment the list is on screen.
|
||||
val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) }
|
||||
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) }
|
||||
// Whether the saved position is still being put back -- the history it needs fetched, and the
|
||||
// scroll applied. Nothing is drawn while it is: opening at the newest end and then travelling
|
||||
// to the anchor is exactly the journey a reader must never see, and this transcript is not
|
||||
// allowed to move under one.
|
||||
var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) }
|
||||
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) }
|
||||
// Sent, but not yet read by the session -- which is when the backend
|
||||
// records it and it comes back as a row. Until then it is drawn below
|
||||
// the working indicator, because that is where it is in the session's
|
||||
@@ -400,6 +419,31 @@ fun SessionScreen(
|
||||
val currentUnits by rememberUpdatedState(units)
|
||||
val lastTouch = remember { LastTouch() }
|
||||
|
||||
/**
|
||||
* Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to
|
||||
* it.
|
||||
*
|
||||
* One function rather than a clearing written at each of the three places that need it -- a
|
||||
* stream reset, a cached transcript the server turns out not to have, and Reload -- because
|
||||
* what has to go is a property of "these rows are no longer continuous with what comes next",
|
||||
* not of who noticed. The two easy ones to leave out are [queued] and [waitingCommands]: both
|
||||
* are folded from events, so a `messageQueued` whose resolving `userMessage` fell in the gap
|
||||
* draws a bubble waiting for a message the session read long ago. [contextTokens] needs no
|
||||
* clearing, because `UsageDelta.context` is absolute and the next one corrects it.
|
||||
*
|
||||
* The resume cursor is deliberately *not* cleared here: a reset continues from where it was,
|
||||
* and only a caller that is starting the conversation again from the server says so itself.
|
||||
*/
|
||||
fun dropLoadedTranscript() {
|
||||
items = listOf()
|
||||
replies.clear()
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
queued = listOf()
|
||||
waitingCommands = listOf()
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the transcript list draws, from one event.
|
||||
*
|
||||
@@ -614,14 +658,7 @@ fun SessionScreen(
|
||||
// `items` read below happens back on the caller's thread, where the write does too.
|
||||
val page =
|
||||
withContext(Dispatchers.IO) {
|
||||
val older =
|
||||
fetchTranscript(
|
||||
settings,
|
||||
summary.id,
|
||||
before = oldestSeq,
|
||||
limit = limit,
|
||||
coalesce = coalesce,
|
||||
)
|
||||
val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce)
|
||||
if (older.isEmpty()) return@withContext null
|
||||
// Folded oldest-first into a list of their own, then put in front: `foldEvent`
|
||||
// merges streaming text into the item before it, so replaying an older page
|
||||
@@ -694,17 +731,22 @@ fun SessionScreen(
|
||||
// The stream lifecycle: connect, follow, and on any drop reconnect
|
||||
// from the cursor -- so a flaky link (or a backend restart) costs
|
||||
// nothing but the gap's latency.
|
||||
// The newest page first, in one request, before the stream opens. The
|
||||
// stream then starts from where that page ended, so it carries live
|
||||
// events only -- which is what it is good at.
|
||||
LaunchedEffect(summary.id) {
|
||||
try {
|
||||
val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) }
|
||||
// Warmed before the fold lands rather than after: flattening the rows into units
|
||||
// splits every settled reply ([transcriptUnits]), and the flatten runs in the
|
||||
// composition that first sees the rows. Folded into a scratch list off this thread
|
||||
// to find out what needs warming; the real fold below also maintains the queue and
|
||||
// the cursor, so it cannot be reused here.
|
||||
// The newest window first, before the stream opens, so the stream starts from where that
|
||||
// window ended and carries live events only -- which is what it is good at. The window comes
|
||||
// from this phone's own copy when there is one, and then costs a single request to check
|
||||
// that the server's transcript is still the one it came from; otherwise it is a page fetched
|
||||
// as it always was. See TRANSCRIPT_CACHE.md.
|
||||
LaunchedEffect(summary.id, epoch) {
|
||||
/**
|
||||
* One opening window onto the screen, whichever side it came from.
|
||||
*
|
||||
* Warmed before the fold lands rather than after: flattening the rows into units splits
|
||||
* every settled reply ([transcriptUnits]), and the flatten runs in the composition that
|
||||
* first sees the rows. Folded into a scratch list off this thread to find out what needs
|
||||
* warming; the real fold below also maintains the queue and the cursor, so it cannot be
|
||||
* reused here.
|
||||
*/
|
||||
suspend fun open(page: List<SeqEvent>) {
|
||||
withContext(Dispatchers.IO) {
|
||||
var scratch = listOf<TranscriptItem>()
|
||||
page.forEach { entry ->
|
||||
@@ -715,6 +757,58 @@ fun SessionScreen(
|
||||
warm(replies, scratch)
|
||||
}
|
||||
page.forEach { apply(it) }
|
||||
}
|
||||
|
||||
try {
|
||||
// This phone's own copy first, drawn before anything is asked of the server -- which
|
||||
// is the whole point of the cache. What makes it safe to draw before it is checked is
|
||||
// that a failed check replaces these rows, with the same appearance as a reset.
|
||||
val cached = withContext(Dispatchers.IO) { source.cachedOpening() }
|
||||
if (cached != null) {
|
||||
open(cached)
|
||||
// A replay is as old as the last visit; the row this screen was opened from was
|
||||
// fetched moments ago. So the transcript comes from the cache and everything that
|
||||
// is not the transcript comes from the summary, which is the newer measurement of
|
||||
// the same thing -- otherwise a session that finished an hour ago opens saying
|
||||
// "working" until the stream connects, which is a status row lying for a round
|
||||
// trip.
|
||||
status = summary.status
|
||||
model = summary.model
|
||||
permissionMode = summary.permissionMode ?: "auto"
|
||||
if (summary.status != "compacting") compactingSince = null
|
||||
// Nothing to put back, so these rows are the screen and the probe can return
|
||||
// under them. A restore still has history to fetch and is gated below.
|
||||
if (savedAnchor == null) ready = true
|
||||
}
|
||||
// The one thing a cached cursor has to be shown before the stream resumes from it.
|
||||
val usable = cached != null && withContext(Dispatchers.IO) { source.probe() }
|
||||
if (usable) probePassed = true
|
||||
if (!usable) {
|
||||
// Either there was nothing cached, or what was cached is not what the server
|
||||
// has -- the file was replaced or truncated under it. Same clearing as a reset,
|
||||
// then an ordinary cold open.
|
||||
if (cached != null) {
|
||||
dropLoadedTranscript()
|
||||
lastSeq.set(0)
|
||||
}
|
||||
open(withContext(Dispatchers.IO) { source.fetchOpening() })
|
||||
// Refilled from the server, so the tail is the server's by construction.
|
||||
probePassed = true
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
// Not fatal: the stream below still replays from zero, which is slow but complete.
|
||||
// Saying so beats silently showing nothing.
|
||||
//
|
||||
// It is also where a probe that could not be *made* lands -- a phone with no route to
|
||||
// the server. Whatever was cached stays on screen and [probePassed] stays false, so
|
||||
// the stream loop asks again before it resumes from that cursor.
|
||||
streamError = e.message
|
||||
} finally {
|
||||
// However that went, the stream is free to take it from here.
|
||||
probing = false
|
||||
}
|
||||
|
||||
try {
|
||||
// Then back where reading stopped. An anchor deeper than the newest page is exactly
|
||||
// the one worth restoring -- somebody who read to the bottom has no anchor at all --
|
||||
// and the cost was already paid on the way down there.
|
||||
@@ -792,8 +886,8 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
// Not fatal: the stream below still replays from zero, which is
|
||||
// slow but complete. Saying so beats silently showing nothing.
|
||||
// A page of history that never arrived. The reader is left at the newest end rather
|
||||
// than where they were, which is the state this screen opens in anyway.
|
||||
streamError = e.message
|
||||
}
|
||||
// Whatever happened above, including a page that never arrived: an empty transcript is a
|
||||
@@ -816,6 +910,13 @@ fun SessionScreen(
|
||||
loadingHistory = false
|
||||
}
|
||||
}
|
||||
// Last, and off this thread: this session is what must not be evicted, so it is marked
|
||||
// as visited before the budget is applied, and both are a walk of the cache directory
|
||||
// that nothing on screen is waiting for.
|
||||
withContext(Dispatchers.IO) {
|
||||
source.cache.touch()
|
||||
cache.evictToBudget(keep = summary.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Only while the screen is actually on screen. Android stops the
|
||||
@@ -826,16 +927,37 @@ fun SessionScreen(
|
||||
// that has been backgrounded is work nobody is watching. Stopping the
|
||||
// stream deliberately makes the drop a close rather than an error (see
|
||||
// EventStream.close), and resuming reconnects from the same cursor.
|
||||
LaunchedEffect(summary.id, ready, lifecycleOwner) {
|
||||
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) {
|
||||
if (!ready) return@LaunchedEffect
|
||||
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the
|
||||
// cursor under them is still the server's, so `ready` is no longer the whole gate: this
|
||||
// waits for that check to settle. Without it the two run at once, ask the same question
|
||||
// twice, and race each other's answer -- two probes per warm open in the server's log.
|
||||
snapshotFlow { probing }.first { !it }
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
try {
|
||||
while (true) {
|
||||
val stream = EventStream(settings, summary.id)
|
||||
activeStream.set(stream)
|
||||
try {
|
||||
// A cached cursor whose probe never got an answer, because the server
|
||||
// could not be reached when the screen opened. Resuming a stream from an
|
||||
// unchecked cursor is the one thing this must not do, so it is asked
|
||||
// again here, on the reconnect schedule, with the cached rows still on
|
||||
// screen meanwhile. False covers both answers that mean "open cold":
|
||||
// the file is not the one these rows came from, and there was nothing
|
||||
// cached to check.
|
||||
if (!probePassed) {
|
||||
if (withContext(Dispatchers.IO) { source.probe() }) {
|
||||
probePassed = true
|
||||
} else {
|
||||
dropLoadedTranscript()
|
||||
lastSeq.set(0)
|
||||
withContext(Dispatchers.IO) { source.fetchOpening() }
|
||||
.forEach { apply(it) }
|
||||
probePassed = true
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.run(
|
||||
source.follow(
|
||||
after = lastSeq.get(),
|
||||
// Connected, measured rather than inferred: this is what
|
||||
// takes a failure off the screen, and nothing else does.
|
||||
@@ -850,11 +972,10 @@ fun SessionScreen(
|
||||
// is what makes this the same as opening the
|
||||
// screen -- `apply` refills them, and scrolling
|
||||
// up pages the rest back in as it always does.
|
||||
items = listOf()
|
||||
replies.clear()
|
||||
held = listOf()
|
||||
oldestSeq = 0L
|
||||
moreHistory = true
|
||||
// The cache needs no telling: the window's first
|
||||
// seq is not the seq it was expecting, which is
|
||||
// what closes its live run and starts another.
|
||||
dropLoadedTranscript()
|
||||
},
|
||||
) { entry ->
|
||||
apply(entry)
|
||||
@@ -870,7 +991,7 @@ fun SessionScreen(
|
||||
// closing the app over. Reported on the screen either way.
|
||||
streamError = e.message ?: e::class.simpleName
|
||||
} finally {
|
||||
stream.close()
|
||||
source.close()
|
||||
}
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
}
|
||||
@@ -878,14 +999,15 @@ fun SessionScreen(
|
||||
// Cancellation -- going below STARTED, or leaving the screen --
|
||||
// cannot interrupt a blocking socket read. Closing is what
|
||||
// unblocks it, and what marks the drop deliberate.
|
||||
activeStream.getAndSet(null)?.close()
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
// The screen going away entirely, which the lifecycle scope above does
|
||||
// not cover: a composable can leave the composition while the activity
|
||||
// stays started.
|
||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||
// stays started. Keyed on the epoch as well, so that Reload's replacement
|
||||
// source is the one a later disposal closes.
|
||||
DisposableEffect(summary.id, epoch) { onDispose { source.close() } }
|
||||
|
||||
// Nothing gets announced about the session somebody is reading; see NotificationService.
|
||||
// RESUMED rather than STARTED because "looking at it" means the foreground -- a session left
|
||||
@@ -1936,10 +2058,32 @@ fun SessionScreen(
|
||||
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false })
|
||||
}
|
||||
if (settingsOpen) {
|
||||
// Measured when the dialog opens rather than kept up to date: what the reader is being
|
||||
// told is what pressing the button now would discard, and null until the walk of the
|
||||
// directory returns is what not knowing looks like.
|
||||
var cachedBytes by remember(summary.id, epoch) { mutableStateOf<Long?>(null) }
|
||||
LaunchedEffect(summary.id, epoch) {
|
||||
cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() }
|
||||
}
|
||||
SessionSettingsDialog(
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
title = title,
|
||||
cachedBytes = cachedBytes,
|
||||
// The purge finishes before the epoch moves, because the relaunched opening effect
|
||||
// reads the same directory and would otherwise draw what is about to be deleted.
|
||||
// Everything else here is the clearing a cold open needs; the epoch is what makes it
|
||||
// one, by rebuilding the opening effect, the stream, and the anchor being put back.
|
||||
onReload = {
|
||||
settingsOpen = false
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { source.cache.purge() }
|
||||
dropLoadedTranscript()
|
||||
lastSeq.set(0)
|
||||
ready = false
|
||||
epoch++
|
||||
}
|
||||
},
|
||||
// The header takes the new name at once and the dialog closes on it, because the
|
||||
// rename has already been accepted by the server -- see [title], which is this app's
|
||||
// own datum. The list behind this refetches on the way out of the session anyway.
|
||||
|
||||
Reference in new issue
Block a user