Skip to content

fix(browser): keep media playback streaming while records are discovered - #209

Merged
mickvandijke merged 5 commits into
mainfrom
fix/browser-media-streaming
Oct 1, 2026
Merged

mickvandijke merged 5 commits into
mainfrom
fix/browser-media-streaming

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Linear issue

Closes V2-1355

Summary

Streaming a 612 MB mainnet video through the browser SDK froze after about eight seconds. One range read hung for 45 to 60 seconds, past the media service worker's 45 second block timeout. Diagnostic builds showed why:

  • Speculative reads spent the twenty-peer early allowance on connected peers far from the record within about two seconds.
  • Discovery offered the record's actual holders about ten seconds in, but they went unread until discovery finished, 40 to 55 seconds later.
  • BrowserFileReader fetched each record only when playback needed it, so every record's discovery landed on playback.

This PR has four commits:

  1. fix(read), shared by native and WASM retrieve_progressive: after the early allowance is used, a hint with fewer than CLOSE_GROUP_SIZE attempted peers nearer the target stays eligible. Stale hints stay bounded, and speculative GETs are capped at twice the allowance. The two-GET limit, hedge delay and absence rules are unchanged.
  2. feat(browser), read-ahead in BrowserFileReader:
    • A sequential read fetches up to four records of the next 32 MiB in parallel. A read's own records go through read-ahead too, so no record is fetched twice.
    • Readers hold these records themselves and release those behind them first. The client's shared chunk cache keeps only eight records, fewer than a window spans, so reading ahead through it would evict records before their read and fetch them again without end.
    • A failed fetch is not retried in the background. A read that needs the record fetches it itself, and each later read retries it once.
    • Fetches left behind by a seek continue until five are in flight; then each new fetch cancels the most recently started one.
    • A streaming reader treats every read as sequential, so a seek fetches the window alongside its first read. A streaming read from the start of the file also fetches the last record, where the MP4/WebM index usually is. Other readers read ahead only once a read continues an earlier one. The browser SDK enables streaming for media sources only (fix(media): stream media with ant-core 0.11.0 read-ahead (release 0.1.1) ant-browser-sdk#2).
    • range_records and read-ahead share one RecordLayout.
  3. fix(browser), read-ahead bounds that do not trust DataMap metadata. Record sizes are only checked in total, so read-ahead counts records and never declared bytes:
    • The window takes at most ten records, as many as full-size records fill, so undersized records shorten it rather than lengthen it.
    • A fetch is admitted only while fewer than five are in flight and fewer than 24 records are held or in flight. Otherwise it is refused, and the read fetches the refused records itself.
    • Once a read's records fail to decrypt, the reader stops reading ahead.
  4. fix(browser), read-ahead bounded across GETs, readers and concurrent reads:
    • Speculative GETs. A cancelled fetch's GETs already on the wire keep their read permits until the response drains, so cancelling never freed budget. Read-ahead now fetches through its own client. Its GETs are speculative unless a read in progress waits for their record. Speculative GETs, draining ones included, hold at most half the read budget's cap, so reads always have the rest. A queued speculative GET is admitted as an ordinary one once a read comes to wait for its record. Read-ahead reserves the budget before peer admission, so a queued speculative GET holds no session slot that a read to the same peer needs.
    • Cache and adaptive limit. Read-ahead keeps its records out of the shared chunk cache, and takes a record a read already cached. Only fetches a read waited for feed the reads' adaptive fetch limit.
    • One budget per client. All readers of a client share 24 records, held or in flight: the window and last record of two streams, plus two. The budget is derived from the window, so a smaller chunk size cannot break the build. Over budget, a reader idle for a minute loses its records first, so a reader that JavaScript drops without closing keeps nothing another reader needs.
    • Concurrent reads. Every read in progress keeps its records, not only the latest. A read waits for its read-ahead fetches alongside fetching the records refused to it, never before them. A failed read-ahead fetch counts as the read's first attempt, so a read makes as many attempts as before.
    • Edge cases. A read that needs no records, such as a zero-length one, changes nothing. A DataMap that range reads reject, such as one with non-contiguous indices, opens with read-ahead off, and its reads report the error.
    • Options object. openPublicFile/openPrivateFile take { streaming: true } as an optional third argument, in place of a positional flag.

The ADR-0004 amendment records the decision and the measurements.

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Compatibility

  • Wire: none. Message formats are unchanged. Reads send more speculative GETs toward the target during slow discovery, and sequential readers fetch up to four records ahead.
  • Storage: none
  • API: additive. BrowserNetworkClient.openPublicFile and openPrivateFile take an optional third options argument, { streaming }; existing callers are unaffected. ant-browser-sdk#2 must pass { streaming: true } rather than true, which an earlier revision of this PR took. The readers of one client hold or fetch at most 24 records of read-ahead together, each at most one browser response (about 100 MB of full-size records), whatever their DataMaps declare. Before, range reads were cached in the client's 32 MiB chunk cache only.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

  • cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, and cargo clippy -p ant-core --target wasm32-unknown-unknown --no-default-features --features browser-wasm[,test-utils] -- -D warnings all pass.
  • cargo test --lib --all: 753 passed.
    • client_engine::read: a_hint_near_the_target_is_read_after_stale_hints_use_the_allowance fails with the old rule and passes with the new one; a_far_hint_is_not_read_after_the_allowance_is_used keeps stale hints bounded; speculative_reads_stop_at_twice_the_allowance checks the cap.
    • client_engine::read_budget: speculative reads leave half the cap to awaited reads, a queued speculative read is admitted once a caller waits for it, and under a cap of one only awaited reads are admitted.
    • client_engine::files tests RecordLayout; client_engine::read_ahead tests the release plan and undersized_records_shorten_the_window_instead_of_lengthening_it.
  • WASM bindings (browser-wasm,test-utils, node --test wasm-tests/*.test.mjs): 195 passed. Read-ahead tests run against a mock node that multiplexes, as real nodes do:
    • A streaming reader serves a later record from read-ahead, while an ordinary reader fetches it.
    • Streaming read-ahead of a 12-record file fetches each record once, then goes quiet.
    • An ordinary reader's header read fetches two records, also after a zero-length read; it reads ahead only once a read continues it.
    • A failed record is retried only after another read.
    • A seek's read finishes at once while read-ahead GETs it cancelled still drain.
    • A read fetches the records read-ahead refuses it alongside the admitted ones.
    • A second read does not cancel a first read's fetch: that record is fetched once.
    • A DataMap of 40 records that each declare 1 KiB: read-ahead fetches exactly 11 records, keeps at most 5 in flight and 24 held or in flight, and stops after a read fails to decrypt. Three readers of it hold or fetch at most 24 together.
    • A DataMap with non-contiguous indices opens, and its reads report the error.
    • Removing the speculative share, the shared budget, the protection of concurrent reads, the zero-length rule or the concurrent fetch of refused records each fails its test.
  • Mainnet, headless Chromium, public 612 MB video 134e4537…a889, through the SDK example and ant-file-vault, on the first revision, which read ahead through the shared chunk cache. To be repeated on this revision before merge.
    • Before: froze permanently at 8.5 s; reads took 43 to 60 s.
    • After: played more than two minutes without a stall, with about 30 s buffered; occasional reads of several seconds were absorbed by the buffer.
    • Seeks to 7:00–11:00 (four runs): playback resumed in 2 to 21 s. Three runs then played without a stall; one paused once, for about 12 s.
    • First frame: 16 to 26 s after connecting.
    • The remaining waits are bound by discovery latency, which is unchanged here.
  • I did not run a dev testnet locally for this PR. The Real Chromium and local nodes and E2E jobs in this PR's CI cover browser and native reads against local nodes.

New dependency

none

ADR

https://github.com/WithAutonomi/ant-client/blob/8c765e0/docs/adr/ADR-0004-direct-browser-read-client.md#streaming-reads-2026-09-28

Mitigation / rollback

Revert fix(read) on its own, or the three browser commits together. Read-ahead is limited to BrowserFileReader; its window, concurrency, in-flight and held-record bounds are constants in browser/wasm_transport/read_ahead.rs, and the speculative share of the read budget is a constant in client_engine/read_budget.rs.

🤖 Generated with Claude Code

mickvandijke and others added 2 commits September 29, 2026 15:06
Speculative reads start from connected peers, which are usually far from
the record. On mainnet they spent the twenty-peer allowance within two
seconds, so holders that browser discovery offered about ten seconds in
went unread until discovery completed, 40 to 55 seconds later. Media
playback froze on those reads.

A hint with fewer than a close group of attempted peers nearer the target
may hold the record, so it stays eligible after the allowance is used.
Stale hints remain bounded by the allowance, and every speculative GET by
twice the allowance per round. The two-GET limit, hedge delay and absence
rules are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BrowserFileReader fetched each record only when a read needed it, so every
new record's discovery stalled media playback. A sequential read now moves
a 32 MiB window to its start and fetches up to four records in it in
parallel, alongside the read. A read's own records are fetched the same
way, so a read waits for a record already being fetched rather than
fetching it again.

The reader holds the records it fetches ahead, up to 24, and then releases
those behind it first. The client's shared chunk cache keeps only eight
records, fewer than a window spans, so read-ahead through it would evict
records before their read and fetch them again without end. A window
position fetches each record once. A failed fetch is not retried in the
background: a read that needs the record fetches it itself, and each later
read retries it once.

Fetches left behind by a seek continue until five are in flight; then each
new fetch cancels the most recently started one, which has made the least
progress. Every fetch in flight can run a GET within the client's read
budget of eight. Closing or dropping the reader cancels its fetches and
releases its records.

openPublicFile and openPrivateFile take an optional streaming flag. A
streaming reader treats every read as sequential, so a seek fetches the
window alongside its first read. A streaming read from the start of the
file also fetches the last record, where MP4 and WebM usually keep their
index. Other readers read ahead only once a read continues a recent one,
and otherwise fetch just the next record.

range_records and read-ahead share one RecordLayout. ADR-0004 records the
change and its validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickvandijke
mickvandijke force-pushed the fix/browser-media-streaming branch from e85cc57 to f86e56b Compare September 29, 2026 13:14

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at f86e56b. One resource-bound issue remains (inline).

Evidence: local logs report 133 client_engine tests and 14 WASM download tests passed; GitHub checks were green when checked. These tests do not exercise undersized/misdeclared DataMap records. The custom network probe did not successfully exercise the target path, so this finding is based on source/control-flow inspection, not a claimed end-to-end exploit.

Independent review: the completed lifecycle reviewer found the close/read race non-blocking; recovered GLM-5.2 recommended approval with robustness concerns. I disagree with its resource-bound conclusion: its MAX_WINDOW_RECORDS argument assumes full-size records, which the actual DataMap admission path does not enforce. Timed-out seats are not counted as completed reviews. A narrowly scoped independent resource adjudication remains pending.

Please enforce the read-ahead budget independently of untrusted plaintext-size metadata and add regression coverage before approval.

self.layout.overlapping(anchor, READ_AHEAD_BYTES)
})
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Bound the window by actual record/byte admission, not only declared plaintext sizes. window() includes every record overlapping 32 MiB according to DataMap src_size. RecordLayout::new checks contiguous indices and sum overflow, but does not enforce full-size records; resolve_data_map checks total size and minimum chunk count, not individual sizes. A root map with 40 contiguous records declaring 1 KiB each therefore places all 40 in this window. fill() keeps fetching the window as earlier fetches settle, successful results become Held, and after_read() exempts the whole window from eviction. The advertised 24-held-record bound is consequently not enforced. Content-hash verification does not tie ciphertext allocation size to the declared src_size. This is introduced amplification: the previous one-byte range fetched only overlapping records; the new streaming read can fetch/retain the entire malformed window, even after foreground decryption fails. Please cap speculative record/byte admission and retention independently of map metadata (or reject incompatible layouts before scheduling), and add an undersized/misdeclared-map regression. Also make prefetch refuse admission when cancel_stale cannot free capacity, rather than treating its return as permission to exceed MAX_IN_FLIGHT.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, confirmed. It was also worse than described: before_read admitted every record a read needed, so one range read over tiny declared records could start a fetch per record. Fixed in 5fc1349:

  • Window capped by records. At most MAX_WINDOW_RECORDS (10, what full-size records fill), so undersized records shorten the window instead of lengthening it.
  • Strict admission. prefetch admits a fetch only while fewer than MAX_IN_FLIGHT (5) are in flight and fewer than MAX_HELD_RECORDS (24) are held or in flight, after cancelling stale fetches and releasing held records behind the reader. Otherwise it refuses, and the read fetches the refused records itself through the existing bounded-concurrency path. Each record is at most one browser response, so held memory is bounded whatever the map declares.
  • Stop on decrypt failure. Once a read fails with an encryption error, the reader stops reading ahead and releases what it holds.

Regression coverage: read-ahead of a misdeclared DataMap stays within its record bounds and stops when a read fails publishes 40 records that each declare 1 KiB (through a new test-only test_encode_public_data_map) and samples read-ahead through a test-only testReadAhead(). It asserts at most 11 records fetched ahead, at most 5 in flight, at most 24 held or in flight, and nothing held after the failed read. I checked it against three mutants, each of which fails the test:

  • without the window cap: read ahead 24 records
  • without admission refusal: 29 records in flight
  • without the stop: read-ahead state not released

The native test undersized_records_shorten_the_window_instead_of_lengthening_it covers the planner.

I didn't take the "reject incompatible layouts" route. self_encryption keeps its chunk-size schedule pub(crate) and supports a variable max chunk size, so re-deriving a canonical layout here could reject legitimate maps.

One gap predates this PR and is out of scope: the foreground range read is also bounded by declared plaintext, not records. A 4 MiB readRange over tiny declared records fetches every overlapping record, at bounded concurrency, and holds them all until decryption. I'd suggest tracking that as a separate issue.

Checks: cargo fmt, native and WASM clippy clean; cargo test --lib --all 750 passed; WASM bindings 189 passed.

mickvandijke and others added 3 commits October 1, 2026 10:58
DataMap record sizes are untrusted: opening a file checks only the total
size and record count. Read-ahead sized its window by declared plaintext,
so a map of forty records that each declare one KiB put every record in
the 32 MiB window. All of them were fetched and held past the 24-record
budget, because the window is never released, and each record can still be
a full browser response. A read's own records were also admitted without a
bound, so one range read over tiny records could start a fetch per record.

Read-ahead now counts records and never trusts declared bytes. The window
takes at most ten records, as many as full-size records fill, so undersized
records shorten it. A fetch is admitted only while fewer than five are in
flight and fewer than 24 records are held or in flight, after cancelling
stale fetches and releasing held records behind the reader. Otherwise it
is refused, and a read fetches the records refused to it itself. Once a
read's records fail to decrypt, the reader stops reading ahead.

A test-only DataMap encoder and read-ahead counter let a WASM test publish
such a map and check each bound. The test fails if any of the three fixes
is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cancelling a stale read-ahead fetch dropped its future, but the transport
keeps a GET that has sent bytes, and its read permit, until the response
drains. Five fetches with hedges could also take ten GETs from a budget of
eight. Read-ahead now fetches through a client of its own whose GETs are
speculative unless a read waits for their record. Speculative GETs,
draining ones included, hold at most half the read budget's cap, and a
queued one is admitted as an ordinary GET once a read comes to wait for
its record. They reserve the budget before peer admission, so a queued
speculative GET holds no session slot that a read to the same peer needs.
Read-ahead keeps its records out of the shared chunk cache, and only
fetches a read waited for inform the reads' adaptive concurrency.

The 24-record budget applied per reader, so several readers, or readers
JavaScript dropped without closing, held about 100 MB each. The readers of
one client now share it, sized from the window for two streams, so a
smaller chunk size cannot break the build. Over budget, an idle reader's
records go first, then those behind each reader. Every read in progress
keeps its records, not only the latest, so a concurrent read cannot cancel
a fetch another read waits for.

A read no longer waits for its read-ahead fetches before fetching the
records read-ahead refused it: it waits for each record alongside the
others, and a failed read-ahead fetch counts as its first attempt. A read
that needs no records changes nothing, so a zero-length read no longer
makes the next read sequential. A DataMap whose layout range reads reject
opens with read-ahead off instead of failing to open. openPublicFile and
openPrivateFile take an options object, { streaming: true }, in place of
a positional flag.

WASM tests cover each change; removing the speculative share, the shared
budget, the protection of concurrent reads, the zero-length rule or the
concurrent fetch of refused records fails its test. The mock node can
multiplex and reports each GET's address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clippy 1.99 treats a pinned boxed future as must-use, so the #[must_use]
that async-trait puts on every UploadAdapter method now fails
double_must_use, natively and in the browser build. Allow the lint on the
trait rather than move to async-trait 0.1.92, which avoids it but adds
syn 3 to the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mickvandijke
mickvandijke merged commit 1655023 into main Oct 1, 2026
17 of 18 checks passed
mickvandijke added a commit that referenced this pull request Oct 1, 2026
A round of six is more likely than a round of three to include an
unreachable peer, so it waits out the grace period at least as often. The
gain is more answers per round, and therefore fewer rounds, not less
waiting; the ADR, the README and the constant's comment now say so.

The write-up also overstated a few results. Six halved the cold-read mean
only on the M3 Ultra; on the droplet the mean fell by a fifth and the p90
rose slightly. One alpha 3 read timed out on the M3 Ultra, so not every
read succeeded. The runs used #209's read-ahead from before its bounds,
so the six-versus-ten playback comparison should be repeated on the
merged read-ahead.

The README now maps each pkg-vN build to its alpha and grace, names the
run each row comes from, and notes that the first run's dial records are
unusable. The eleven playback logs behind streaming-summary.txt are
committed next to the one that was, and stream-seek.mjs finds vite
relative to its working directory, so SDK_DIR may be any path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants