client-core: an unasked page is not an empty one, and two guarded invariants
Review of 73251d6's port of TranscriptSource/joinPages. `TranscriptSource::page` answered `before == 0` with an empty `Vec`, which is the same value it answers "this conversation has no more history" with. That is the state the Kotlin keeps apart: `loadOlderPage` returns false at `oldestSeq == 0` *without* touching `moreHistory`, and returns false on an empty page *by latching it*. Collapsing the two moved AGENTS.md's paging bug one layer down rather than fixing it. `page` returns `OlderPage` now -- `Events(vec![])` is the start of the conversation, `NothingLoaded` is not an answer about the conversation at all. `join_pages`' `debug_assert!` on seq ordering across the boundary is not a true invariant: a peer note carries the seq its turn began at, which can be older than the page it arrived in, so an ordinary transcript would have panicked a debug build there. Replaced with the one the function exists to enforce -- no tool id surviving in both halves. `fetch_transcript_lines` stores `RawValue`'s exact server bytes, so the "neither source can produce a newline" comment in `SessionCache::append` now rests on the server's serializer staying compact rather than on a local normalization. Checked with a `debug_assert!` in `append` and `store_page` rather than trusted. Tests for the failure half, which the port had none of: a 500 mid-page, a cached line this build cannot read, and the `after` bound in the case that actually carries one (the existing test asserted only the case with no bound). `cargo fmt`, `cargo clippy --all-targets`, `cargo test` (112) clean in client-core; `cargo check -p desktop-app` clean.
This commit is contained in:
1 parent
312455956d
commit
bf3479f5c4
4 files changed
+164
-45
No files matched your search
@@ -361,6 +361,10 @@ impl SessionCache {
|
|||||||
{
|
{
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
debug_assert!(
|
||||||
|
lines.iter().all(|l| !l.contains('\n')),
|
||||||
|
"a stored page's lines must each be one line"
|
||||||
|
);
|
||||||
fs::create_dir_all(&this.dir)?;
|
fs::create_dir_all(&this.dir)?;
|
||||||
let kind = if rows { "rows" } else { "raw" };
|
let kind = if rows { "rows" } else { "raw" };
|
||||||
let mut content = lines.join("\n");
|
let mut content = lines.join("\n");
|
||||||
@@ -389,8 +393,16 @@ impl SessionCache {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
// Written as it arrived. A newline inside it would split one
|
// Written as it arrived. A newline inside it would split one
|
||||||
// event into two unreadable halves, but neither source can
|
// event into two unreadable halves. No source here can produce
|
||||||
// produce one.
|
// one -- an SSE `data:` field cannot hold a raw newline, and a
|
||||||
|
// fetched line is one element of a compact JSON array -- but
|
||||||
|
// that is a fact about the *server's* serializer rather than
|
||||||
|
// anything this file controls, so it is checked rather than
|
||||||
|
// trusted.
|
||||||
|
debug_assert!(
|
||||||
|
!line.contains('\n'),
|
||||||
|
"a cached transcript line must be one line: {line}"
|
||||||
|
);
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
writer.write_all(line.as_bytes())?;
|
writer.write_all(line.as_bytes())?;
|
||||||
writer.write_all(b"\n")?;
|
writer.write_all(b"\n")?;
|
||||||
|
|||||||
@@ -384,15 +384,23 @@ pub fn join_pages(earlier: &[TranscriptItem], later: &[TranscriptItem]) -> Vec<T
|
|||||||
None => true,
|
None => true,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
debug_assert!(
|
|
||||||
match (healed.last(), kept.first()) {
|
|
||||||
(Some(last), Some(first)) => last.seq() <= first.seq(),
|
|
||||||
_ => true,
|
|
||||||
},
|
|
||||||
"a joined page must stay ordered by seq at the boundary"
|
|
||||||
);
|
|
||||||
let mut out = adopt_run(&healed, &kept);
|
let mut out = adopt_run(&healed, &kept);
|
||||||
out.extend(kept);
|
out.extend(kept);
|
||||||
|
// What this function exists to prevent, checked rather than assumed: the same
|
||||||
|
// call drawn twice, once from the page that saw its start and once from the page
|
||||||
|
// that saw its end. Not a seq-ordering check -- a peer note is stamped with the
|
||||||
|
// seq its turn began at, which can be older than the page it arrived in, so the
|
||||||
|
// two pages' seqs legitimately interleave at the boundary.
|
||||||
|
debug_assert!(
|
||||||
|
{
|
||||||
|
let mut ids: Vec<&str> = out.iter().filter_map(TranscriptItem::as_tool_run).collect();
|
||||||
|
let before = ids.len();
|
||||||
|
ids.sort_unstable();
|
||||||
|
ids.dedup();
|
||||||
|
ids.len() == before
|
||||||
|
},
|
||||||
|
"join_pages left the same tool call in both halves"
|
||||||
|
);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,26 @@ impl From<ParseError> for PageError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What [`TranscriptSource::page`] found, kept as two states rather than
|
||||||
|
/// one possibly-empty list.
|
||||||
|
///
|
||||||
|
/// The difference is the whole of AGENTS.md's `loadOlderPage` incident: an
|
||||||
|
/// empty [`Self::Events`] means "this conversation has no more history",
|
||||||
|
/// which a caller is meant to latch, and [`Self::NothingLoaded`] means the
|
||||||
|
/// question could not be asked yet, which it must not. Collapsing the two
|
||||||
|
/// into an empty `Vec` puts the bug back, because the caller cannot tell
|
||||||
|
/// them apart -- and `unwrap_or_default()` on an `Option` would do the
|
||||||
|
/// same silently.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum OlderPage {
|
||||||
|
/// The events before the cursor, oldest first. Empty means the start
|
||||||
|
/// of the conversation has been reached.
|
||||||
|
Events(Vec<SeqEvent>),
|
||||||
|
/// Nothing is loaded, so there was no cursor to page back from
|
||||||
|
/// (`before == 0`). Not an answer about the conversation at all.
|
||||||
|
NothingLoaded,
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
fn parse_line(line: &str) -> Result<SeqEvent, ParseError> {
|
||||||
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
serde_json::from_str(line).map_err(|e| ParseError(format!("{e}")))
|
||||||
}
|
}
|
||||||
@@ -174,35 +194,30 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
/// The page before `before`: from the cache when it holds it,
|
/// The page before `before`: from the cache when it holds it,
|
||||||
/// otherwise from the server bounded by what the cache already has.
|
/// otherwise from the server bounded by what the cache already has.
|
||||||
///
|
///
|
||||||
/// `before == 0` always answers an empty page without asking the
|
|
||||||
/// cache or the server anything -- see AGENTS.md's "things that have
|
|
||||||
/// bitten": there is no event before the first one, so a page request
|
|
||||||
/// there is not a harmless no-op, it is indistinguishable from having
|
|
||||||
/// reached the start of history and would latch a caller's "there is
|
|
||||||
/// more" flag false forever. Guarded here rather than left to every
|
|
||||||
/// caller, because it is a fact about the question, not about who is
|
|
||||||
/// asking it.
|
|
||||||
///
|
|
||||||
/// The server bound (`after`) is what keeps the cache worth having. A
|
/// The server bound (`after`) is what keeps the cache worth having. A
|
||||||
/// coalesced page reaches back as far as its row count takes it -- a
|
/// coalesced page reaches back as far as its row count takes it -- a
|
||||||
/// single reply is hundreds of lines -- so a page fetched after the
|
/// single reply is hundreds of lines -- so a page fetched after the
|
||||||
/// reader has been away could run straight past the cached run and
|
/// reader has been away could run straight past the cached run and
|
||||||
/// overlap it, and an overlapping page cannot be stored. Told where
|
/// overlap it, and an overlapping page cannot be stored. Told where
|
||||||
/// this phone's copy starts, the server stops there instead.
|
/// this phone's copy starts, the server stops there instead.
|
||||||
pub fn page(
|
///
|
||||||
&self,
|
/// `before == 0` answers [`OlderPage::NothingLoaded`] without asking
|
||||||
before: u64,
|
/// the cache or the server anything -- see AGENTS.md's "things that
|
||||||
limit: u32,
|
/// have bitten": there is no event before the first one, so the
|
||||||
coalesce: bool,
|
/// request is not a harmless no-op, and its empty answer is
|
||||||
) -> Result<Vec<SeqEvent>, PageError> {
|
/// indistinguishable from having reached the start of history.
|
||||||
|
/// Guarded here rather than left to every caller, because it is a fact
|
||||||
|
/// about the question, not about who is asking it.
|
||||||
|
pub fn page(&self, before: u64, limit: u32, coalesce: bool) -> Result<OlderPage, PageError> {
|
||||||
if before == 0 {
|
if before == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(OlderPage::NothingLoaded);
|
||||||
}
|
}
|
||||||
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
if let Some(lines) = self.cache.page(before, limit as usize, coalesce) {
|
||||||
return lines
|
let events: Vec<SeqEvent> = lines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|l| parse_line(l).map_err(PageError::from))
|
.map(|l| parse_line(l).map_err(PageError::from))
|
||||||
.collect();
|
.collect::<Result<_, _>>()?;
|
||||||
|
return Ok(OlderPage::Events(events));
|
||||||
}
|
}
|
||||||
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
let after = self.cache.covered_up_to(before).map(|v| v - 1);
|
||||||
let page = self.api.fetch_transcript_lines(
|
let page = self.api.fetch_transcript_lines(
|
||||||
@@ -220,7 +235,9 @@ impl<T: Transport> TranscriptSource<T> {
|
|||||||
self.cache
|
self.cache
|
||||||
.store_page(&lines, first_event.seq, before, coalesce);
|
.store_page(&lines, first_event.seq, before, coalesce);
|
||||||
}
|
}
|
||||||
Ok(page.into_iter().map(|(_, event)| event).collect())
|
Ok(OlderPage::Events(
|
||||||
|
page.into_iter().map(|(_, event)| event).collect(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`event_stream::follow_session_events`], with every frame written to
|
/// [`event_stream::follow_session_events`], with every frame written to
|
||||||
@@ -421,8 +438,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let transport = ScriptedTransport::default();
|
let transport = ScriptedTransport::default();
|
||||||
let source = source(transport, dir.path());
|
let source = source(transport, dir.path());
|
||||||
let page = source.page(0, 80, true).unwrap();
|
assert_eq!(source.page(0, 80, true).unwrap(), OlderPage::NothingLoaded);
|
||||||
assert!(page.is_empty());
|
|
||||||
assert_eq!(source.api.transport().call_count(), 0);
|
assert_eq!(source.api.transport().call_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +451,9 @@ mod tests {
|
|||||||
source.fetch_opening().unwrap();
|
source.fetch_opening().unwrap();
|
||||||
|
|
||||||
let calls_before = source.api.transport().call_count();
|
let calls_before = source.api.transport().call_count();
|
||||||
let page = source.page(2, 10, true).unwrap();
|
let OlderPage::Events(page) = source.page(2, 10, true).unwrap() else {
|
||||||
|
panic!("a cursor of 2 is a real question about the conversation");
|
||||||
|
};
|
||||||
assert_eq!(page.len(), 1);
|
assert_eq!(page.len(), 1);
|
||||||
assert_eq!(page[0].seq, 1);
|
assert_eq!(page[0].seq, 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -445,17 +463,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// With nothing older cached there is no floor to give the server, so
|
||||||
|
/// the request carries no `after` at all.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_server_page_is_bounded_by_what_the_cache_already_covers() {
|
fn a_server_page_with_nothing_older_cached_carries_no_bound() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let transport = ScriptedTransport::default();
|
let transport = ScriptedTransport::default();
|
||||||
transport.respond(200, format!("[{}]", status_line(5)));
|
transport.respond(200, format!("[{}]", status_line(5)));
|
||||||
let source = source(transport, dir.path());
|
let source = source(transport, dir.path());
|
||||||
source.fetch_opening().unwrap();
|
source.fetch_opening().unwrap();
|
||||||
// The cache now covers seq 5 onward with nothing older, so covered_up_to(5)
|
|
||||||
// is None (nothing stored below it) -- fetch a page further back and confirm
|
|
||||||
// the request the cache-less path makes carries no `after` in that case, then
|
|
||||||
// a second page that the cache *does* bound.
|
|
||||||
let transport2 = ScriptedTransport::default();
|
let transport2 = ScriptedTransport::default();
|
||||||
transport2.respond(200, format!("[{}]", status_line(3)));
|
transport2.respond(200, format!("[{}]", status_line(3)));
|
||||||
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
@@ -467,6 +484,70 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The half the test above cannot show: when the cache *does* hold an
|
||||||
|
/// older run, the fetch is floored at its end, or the page would run
|
||||||
|
/// straight past it and overlap -- which `store_page` then refuses,
|
||||||
|
/// silently costing the phone the page it just paid for.
|
||||||
|
#[test]
|
||||||
|
fn a_server_page_is_floored_at_the_end_of_the_cached_run() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
// A stored page covering [3, 6) and two live events above it, so the run this
|
||||||
|
// phone holds is [3, 8) -- the newest chunk has to be an appended one, or the
|
||||||
|
// cache reads the directory as damaged and discards it.
|
||||||
|
let lines: Vec<String> = (3..6).map(status_line).collect();
|
||||||
|
assert!(cache.store_page(&lines, 3, 6, true));
|
||||||
|
cache.append(&status_line(6), 6);
|
||||||
|
cache.append(&status_line(7), 7);
|
||||||
|
cache.flush();
|
||||||
|
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(200, format!("[{}]", status_line(9)));
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
source.page(10, 10, true).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().calls.lock().unwrap()[0],
|
||||||
|
"/sessions/s1/transcript?limit=10&before=10&coalesce=true&after=7",
|
||||||
|
"the fetch must stop one seq below where this phone's copy ends"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page the server could not answer is an error, never an empty
|
||||||
|
/// page: the caller would read the second as "this conversation has no
|
||||||
|
/// more history" and stop paging for good.
|
||||||
|
#[test]
|
||||||
|
fn a_failing_server_page_is_an_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
transport.respond(500, "server on fire");
|
||||||
|
let source = source(transport, dir.path());
|
||||||
|
assert!(matches!(source.page(9, 10, true), Err(PageError::Api(_)),));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cached line this build cannot read is told apart from the network
|
||||||
|
/// failing, for the same reason: neither is "no more history".
|
||||||
|
#[test]
|
||||||
|
fn an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let cache = crate::transcript_cache::TranscriptCache::new(dir.path()).session("s1");
|
||||||
|
cache.store_page(
|
||||||
|
&[r#"{"seq":3,"but":"not an event"}"#.to_string()],
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
cache.append(&status_line(4), 4);
|
||||||
|
cache.flush();
|
||||||
|
let transport = ScriptedTransport::default();
|
||||||
|
let source = TranscriptSource::new(ApiClient::new(transport), "s1", cache);
|
||||||
|
assert!(matches!(source.page(4, 10, true), Err(PageError::Parse(_)),));
|
||||||
|
assert_eq!(
|
||||||
|
source.api.transport().call_count(),
|
||||||
|
0,
|
||||||
|
"a cache hit that cannot be read must not fall through to the server unnoticed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
fn a_bad_cached_opening_line_purges_rather_than_panicking() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
+27
-9
@@ -104,7 +104,14 @@ landing cleanly between two already-finished calls (most of them) leaves
|
|||||||
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
one run drawn as two. `a_call_split_across_the_boundary_merges_into_one_row`,
|
||||||
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
`a_message_split_across_the_boundary_is_rejoined_with_the_newer_halfs_identity`,
|
||||||
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
and `adopt_run_never_renames_into_a_question_row` cover the other three
|
||||||
edges the Kotlin doc calls out.
|
edges the Kotlin doc calls out. `join_pages` ends in a `debug_assert!`
|
||||||
|
that no tool id survives in both halves -- the duplicate row it exists to
|
||||||
|
prevent, checked rather than assumed. What it deliberately does *not*
|
||||||
|
assert is seq ordering across the boundary: a peer note carries the seq
|
||||||
|
its turn began at (`place_peer_note`), which can be older than the page
|
||||||
|
it arrived in, so the two pages' seqs legitimately interleave there. An
|
||||||
|
earlier draft asserted it and would have panicked in debug builds on an
|
||||||
|
ordinary transcript.
|
||||||
|
|
||||||
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
**Known gap, and a decision for whoever closes it:** `event_model::Event`
|
||||||
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
has no `Unknown`/catch-all variant, unlike `Events.kt`'s hand-kept mirror.
|
||||||
@@ -142,14 +149,25 @@ wrong") and `page`'s cache-vs-server split bounded by `covered_up_to`.
|
|||||||
Two additions beyond a literal port, both load-bearing:
|
Two additions beyond a literal port, both load-bearing:
|
||||||
|
|
||||||
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
- **`page(before, ..)` refuses `before == 0` before touching the cache or
|
||||||
the network**, returning an empty page immediately. This is
|
the network**, answering `OlderPage::NothingLoaded`. This is AGENTS.md's
|
||||||
AGENTS.md's `loadOlderPage` incident (`before = 0` is "no event before
|
`loadOlderPage` incident (`before = 0` is "no event before the first
|
||||||
the first one," indistinguishable from "reached the start of history"
|
one," indistinguishable from "reached the start of history" if a caller
|
||||||
if a caller ever asks it) moved out of the Kotlin screen and into this
|
ever asks it) moved out of the Kotlin screen and into this layer, so
|
||||||
layer, so every future caller gets the guard rather than having to
|
every future caller gets the guard rather than having to remember it.
|
||||||
remember it. `paging_before_the_first_event_makes_no_request_at_all`
|
**The return type is `OlderPage`, not a `Vec`, and that is the guard.**
|
||||||
asserts zero transport calls, not just an empty result, since a request
|
The Kotlin's two falses are different answers -- `oldestSeq == 0`
|
||||||
that happens to answer empty is exactly what caused the original bug.
|
returns without touching `moreHistory`, an empty page latches it false
|
||||||
|
-- so a port that answered both with an empty list would have moved the
|
||||||
|
bug rather than fixed it, one layer down and out of sight of the screen
|
||||||
|
that used to hold the check. `OlderPage::Events(vec![])` means the start
|
||||||
|
of the conversation; `OlderPage::NothingLoaded` is not an answer about
|
||||||
|
the conversation at all. Reviewed 2026-09-06.
|
||||||
|
`paging_before_the_first_event_makes_no_request_at_all` asserts zero
|
||||||
|
transport calls, not just the variant, since a request that happens to
|
||||||
|
answer empty is exactly what caused the original bug, and
|
||||||
|
`a_failing_server_page_is_an_error_rather_than_an_empty_one` plus
|
||||||
|
`an_unreadable_cached_page_is_a_parse_error_rather_than_an_empty_one`
|
||||||
|
are the same rule for the two ways a page can fail.
|
||||||
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
- **`fetch_transcript_lines`** (new in `api.rs`) hands back each line
|
||||||
paired with the exact server bytes it came from, via
|
paired with the exact server bytes it came from, via
|
||||||
`serde_json::value::RawValue` rather than re-serializing a parsed
|
`serde_json::value::RawValue` rather than re-serializing a parsed
|
||||||
|
|||||||
Reference in new issue
Block a user