From 031424a59895faf22af70f44cfd9c0dee450c610 Mon Sep 17 00:00:00 2001 From: Hasky <239224029+hasky00@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:01:23 +0200 Subject: [PATCH] fix(buzz-db): return an empty page from get_channel_window when limit is 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_channel_window derived `has_more` from the pre-truncation `limit + 1` probe but read `next_cursor` from the truncated row set, so a `limit == 0` call returned `has_more = true` with `next_cursor = None` — breaking the documented `Some` iff `has_more` invariant and panicking or infinitely looping any caller that trusts it. Guard `limit == 0` and return an empty, exhausted page, which also skips a needless database round-trip. Adds a Postgres-gated regression test. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Hasky <239224029+hasky00@users.noreply.github.com> --- crates/buzz-db/src/thread.rs | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 3f92212dd5..fc4bd65d04 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -570,6 +570,19 @@ pub async fn get_channel_window( cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, ) -> Result { + // A zero-limit window retains no rows, so there is no last-retained row to + // derive a cursor from. The `limit + 1` probe below also can't tell "no + // rows" from "more rows" at limit 0: it would report `has_more = true` with + // `next_cursor = None`, breaking the `Some` iff `has_more` invariant and + // stalling any caller that trusts it. Report an empty, exhausted page. + if limit == 0 { + return Ok(ChannelWindow { + rows: Vec::new(), + has_more: false, + next_cursor: None, + }); + } + let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id let mut sql = String::from( r#" @@ -1664,6 +1677,48 @@ mod tests { assert!(page2.next_cursor.is_none(), "no cursor past the last row"); } + /// A `limit == 0` request must return an empty, exhausted page rather than + /// `has_more = true` with `next_cursor = None`. Without the early guard the + /// `limit + 1` probe fetches one row, reports `has_more`, then truncates it + /// away — leaving no row to build a cursor from and breaking the `Some` iff + /// `has_more` invariant a paginating caller relies on. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_zero_limit_reports_empty_exhausted_page() { + let pool = setup_pool().await; + let author = Keys::generate(); + let (channel, community) = create_test_channel( + &pool, + &format!("window-zero-{}", Uuid::new_v4()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); + + // Rows exist, but a zero-limit request asks for none of them. + for i in 0..3 { + let event = make_stream_event(&author, &format!("row-{i}")); + insert_root(&pool, community, channel.id, &event).await; + } + + let window = get_channel_window(&pool, community, channel.id, 0, None, None) + .await + .expect("fetch zero-limit window"); + assert!(window.rows.is_empty(), "zero-limit window retains no rows"); + assert!( + !window.has_more, + "zero-limit window has no cursor, so it must not report has_more" + ); + assert!( + window.next_cursor.is_none(), + "next_cursor is Some iff has_more" + ); + } + /// Rows with replies carry a thread summary (counts + batched /// participants); rows without replies carry none. This is the join that /// lets the GUI render thread affordances without a per-root fan-out.