fix(browser): keep media playback streaming while records are discovered - #209
Conversation
abd5ae8 to
e85cc57
Compare
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>
e85cc57 to
f86e56b
Compare
dirvine
left a comment
There was a problem hiding this comment.
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) | ||
| }) | ||
| } | ||
|
|
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
prefetchadmits a fetch only while fewer thanMAX_IN_FLIGHT(5) are in flight and fewer thanMAX_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.
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>
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>
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:
BrowserFileReaderfetched each record only when playback needed it, so every record's discovery landed on playback.This PR has four commits:
fix(read), shared by native and WASMretrieve_progressive: after the early allowance is used, a hint with fewer thanCLOSE_GROUP_SIZEattempted 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.feat(browser), read-ahead inBrowserFileReader:range_recordsand read-ahead share oneRecordLayout.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:fix(browser), read-ahead bounded across GETs, readers and concurrent reads:openPublicFile/openPrivateFiletake{ 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
Compatibility
BrowserNetworkClient.openPublicFileandopenPrivateFiletake an optional third options argument,{ streaming }; existing callers are unaffected. ant-browser-sdk#2 must pass{ streaming: true }rather thantrue, 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
Test evidence
cargo fmt --all -- --check,cargo clippy --all-targets --all-features -- -D warnings, andcargo clippy -p ant-core --target wasm32-unknown-unknown --no-default-features --features browser-wasm[,test-utils] -- -D warningsall pass.cargo test --lib --all: 753 passed.client_engine::read:a_hint_near_the_target_is_read_after_stale_hints_use_the_allowancefails with the old rule and passes with the new one;a_far_hint_is_not_read_after_the_allowance_is_usedkeeps stale hints bounded;speculative_reads_stop_at_twice_the_allowancechecks 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::filestestsRecordLayout;client_engine::read_aheadtests the release plan andundersized_records_shorten_the_window_instead_of_lengthening_it.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: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.Real Chromium and local nodesand 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 toBrowserFileReader; its window, concurrency, in-flight and held-record bounds are constants inbrowser/wasm_transport/read_ahead.rs, and the speculative share of the read budget is a constant inclient_engine/read_budget.rs.🤖 Generated with Claude Code