Skip to content

id(world_net): record_pages still loops forever on empty pages with an advancing cursor — the #6505 RecordsBudget checks records, not pages, and a non-string next_after silently ends the read #6578

Open

Description

@dezren39

Problem

Residual of #6505 in pkgs/id/src/world_net.rs: the RecordsBudget validation that landed on main (snapshot WIP, repaired in PR #6570) checks records, not pages — so WorldClient::record_pages (:1564) still loops forever on a page shape that carries no records at all, and still treats a malformed cursor as a clean end-of-read.

  1. Empty pages with an advancing cursor loop forever. Each received record is run through RecordsBudget::validate_record (:613, :619), which caps key/value/count/order — but an empty page contributes zero records, so the budget never moves. The cursor checks in the loop are:

    • next_after must equal the page's last key (:1606-1612) — for an empty page there is no last key, and the else branch (:1613-1620) explicitly carves empty pages out, requiring only that this is not the first request (after.is_some());
    • next_after must strictly advance past the cursor sent (:1623-1629) — any larger string satisfies this (z000001, z000002, …).

    Each iteration pushes another (sequence, page) onto the unbounded pages Vec (:1631) and issues another request. There is no page-count bound and no aggregate frame-byte budget on this path (recv_json discards the frame size), so a buggy or malicious host keeps records() / records_sequenced() running forever, growing memory page by page.

  2. A non-string next_after silently ends the read. let next_after = reply["next_after"].as_str() (:1606) yields None for a number, boolean, array, or object cursor exactly as it does for a legitimate JSON null, and the match at :1632 returns Ok(pages) — the caller receives a partial record set presented as complete, with no error. The sibling field in the same reply is handled strictly: sequence is extracted with .as_u64().context("records reply has no sequence")? (:1592-1594).

Neither shape can come from a conforming host: the server side, records_session in src/world_session/mod.rs:1785, sets next_after = more.then_some(last_included).flatten() (:1826), and more is only set after at least one record was included — an honest server never sends an empty page with a cursor, and always sends the cursor as a string or null. The admin-history client path in the same file is the strict oracle: its cursor block (:1740-1755) requires next_after to equal the last received key and to advance past the previous cursor — for an empty page those two requirements contradict each other, so the shape is refused there — and every frame there is charged to a byte budget (budget.add_frame).

Evidence (verified this run, laptop @ origin/main b0c472d)

Probe ~/git/fleet/probe-records-empty-pages (real id crate + rogue iroh host speaking /id-world/1, same harness as the #6505 probe):

  • Control, conforming two pages ({a:1} → next_after:"a", then {b:2} → next_after:null) → Ok, 2 records. ✅
  • Control, empty final page ({a:1} → "a", then {} → null) → Ok, 1 record — the legitimate empty-page shape still terminates. ✅
  • Defect E: first page {a:1} → next_after:"a", every later page {} with next_after:"z<n>" (strictly advancing) → records() was still looping after 3 s; the host saw 55,615 records requests in that window. No error, no return.
  • Defect T: second page {b:2} with next_after:123 (a number) → records() returned Ok with both records, the malformed cursor silently accepted as the terminator.

For contrast, #6505's original shapes are now refused at this HEAD (over-cap page, stuck/non-advancing cursor, out-of-order pages — the budget and the :1606-1629 checks do catch those). This issue is only the two shapes that remain.

Why it matters

The plain records query is the read the CLI uses against worlds the client does not operate (the normal join flow). A host that answers with empty advancing pages — a paging bug is enough; malice is not required — hangs every such read indefinitely with unbounded page accumulation, exactly the hang #6505 set out to close, through the one page shape its fix does not inspect. The non-string cursor case is the quieter twin: a malformed host response is indistinguishable from a complete read, so callers act on a silently truncated record set.

Acceptance criteria

  • record_pages refuses an empty page that carries a next_after cursor (mirroring the admin-history path in the same file), or equivalently bounds the total number of pages / consecutive empty pages with a loud error naming the bound.
  • A present-but-non-string next_after (number, boolean, array, object) is a malformed-reply error, not end-of-pages; only JSON null / absent terminates the loop — the same strictness sequence already gets.
  • Regression tests drive WorldClient against stub/rogue replies covering: empty advancing pages (refused promptly, bounded request count), non-string next_after (error), plus the conforming controls — two-page read and empty final page with next_after: null both still return Ok.
  • Conforming hosts are unaffected: existing world_net / world_session records tests stay green (server pages are non-empty whenever a cursor is present, by construction).

Distinct from #6505 (caps, ordering, and non-advancing cursors in record_pages — those landed and are verified refused at this HEAD): this is the residual page-shape hole in the same loop — empty pages are exempt from the last-key check, and the cursor's type is never checked.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/triageQueue packet kind: verify, reproduce, and route this item
    on Nov 6, 2026
  2. developing-today commented on Nov 6, 2026

    @developing-today
    Contributor
  3. added
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    app/idPackage/app: id (pkgs/ or apps/ in this repo)
    on Nov 6, 2026
  4. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

  5. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 6, 2026
  6. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  7. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · triage verdict — CONFIRMED

  8. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · handed off

  9. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    status/parkedDeliberately paused with a recorded resume condition
    and removed
    queue/claimedQueue worker lifecycle state
    on Nov 6, 2026
  10. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · follow-up enqueued

  11. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    Cross-link from new-issue-creator-code-a (2026-11-06): the admin-history half of this cursor-type conflation is filed as issue #6600. This issue's (#6578) scope excluded admin_event_history as 'already strict' — verified this run that its string-cursor checks are strict, but its cursor type check is absent: match frame["next_after"].as_str() in admin_event_history (world_net.rs:1740) breaks as Ok on a numeric, boolean, or missing cursor, silently truncating the export that world_fixture::record records as complete. Probe: ~/git/fleet/probe-admin-history-wt (laptop), 1 passed. The two loops want the same strictness rule — only JSON null (with the key present, as the conforming sender always emits it) terminates paging — applied in both places.

  12. dezren39 commented on Nov 6, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · design-architect pass 63 — adopted as a phase of design #6622

  13. added
    queue/queuedHas a queued task in the Supabase agent queue
    and removed
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    app/idPackage/app: id (pkgs/ or apps/ in this repo)kind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)queue/queuedHas a queued task in the Supabase agent queuesource/creatorFiled by the new-issue-creator jobsource/design-architectFiled by the repo design architect jobstatus/parkedDeliberately paused with a recorded resume conditiontask/triageQueue packet kind: verify, reproduce, and route this item

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions