diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 9cd1909..3f88bf9 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -113,6 +113,20 @@ private const val HISTORY_SCREENS = 3 */ private const val HISTORY_PAGE = 800 +/** + * The most events one request of a restore may ask for. + * + * A restore knows exactly how far back it has to reach, so it asks for that in one request rather + * than walking there a page at a time. This bounds the request anyway, because "exactly how far" is + * however far the reader had scrolled and there is no bound on that -- and a single response of + * arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an + * event, measured on a real transcript, this is about three megabytes. + * + * Going past it costs another request rather than anything being missed, so the number only trades + * round trips against response size. + */ +private const val RESTORE_PAGE_MAX = 4000 + /** * Which row was asked to hold its top edge, and how tall it was when it last measured. * @@ -879,7 +893,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's * value, which does not change under a running one. */ - suspend fun loadOlderPage(): Boolean { + suspend fun loadOlderPage(limit: Int = HISTORY_PAGE): Boolean { // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, // and the fold is the expensive half: `foldEvent` returns a new list per event, so a page // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around @@ -894,8 +908,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // `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 = HISTORY_PAGE) + val older = fetchTranscript(settings, summary.id, before = oldestSeq, limit = limit) 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 @@ -999,7 +1012,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () // conversation every time an active session was reopened. var index = indexOfSeq(anchor.seq) while (moreHistory && (index == null || index >= listItemCount() - 1)) { - if (!loadOlderPage()) break + // The whole span in one request rather than a page at a time. `read_window` + // counts *lines* and a transcript numbers them one per seq, so the distance + // back to the anchor is the number of events to ask for -- and were seqs ever + // sparse, that difference is larger than the count, which overshoots into + // older history rather than stopping short. [HISTORY_PAGE] on top is the + // cushion that keeps the anchor's row off the oldest edge, where it would + // still grow. + // + // Capped, and the loop is what makes the cap safe: a span past it comes back + // in several requests instead of one, which is what this did for every + // restore until now -- thirteen sequential round trips to reopen a session + // somebody had read a little way back into, and a spinner for all of them. + // The bytes are the same either way, since every row between the anchor and + // the newest end has to be there for the list to be able to count to it. + val span = oldestSeq - anchor.seq + HISTORY_PAGE + if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) break index = indexOfSeq(anchor.seq) } // Both writes before this coroutine yields, so the list's first measurement is diff --git a/server/src/main.rs b/server/src/main.rs index f392a3b..4f37d9c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -132,7 +132,17 @@ async fn main() -> Result<()> { .install_default() .expect("no other TLS crypto provider is installed before main"); - tracing_subscriber::fmt().with_env_filter("info").init(); + // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than as the filter, + // because `with_env_filter("info")` is a fixed directive that never reads the environment -- + // so the per-request diagnostics that AGENTS.md tells you to turn on with + // `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was + // meant to instrument being wrong. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); let args = Args::parse(); let config_path = args