From b909ff93987133182afc235b89ab4b26e276a66d Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:45:27 -0400 Subject: [PATCH 01/12] fix(web): keep the streaming-demo tamper slider where the user put it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset effect re-anchored `tamperByte` to the file's midpoint on every change of the `FileAsset` object, and auto-edit replaces that object every 500 ms — so the slider snapped back to center within half a second of the user releasing it. Clamp to the new byte length instead of re-centering on object identity. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index 07b0ad4b..e025de70 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -265,10 +265,11 @@ function StreamingChunker({ file }: { file: FileAsset }) { }, [api, file]) const [tamperByte, setTamperByte] = useState(Math.floor(file.bytes.byteLength / 2)) - // Re-anchor when file changes so the slider stays in range. + // Clamp (don't re-center) when the file shrinks: auto-edit replaces the FileAsset object every tick without + // changing its length, and re-anchoring on identity snapped the slider back to the midpoint mid-drag. useEffect(() => { - setTamperByte(Math.floor(file.bytes.byteLength / 2)) - }, [file]) + setTamperByte((b) => Math.min(b, file.bytes.byteLength - 1)) + }, [file.bytes.byteLength]) const deferredByte = useDeferredValue(tamperByte) const highlightIndex = useMemo(() => { From f4a81f96e32c34434cd9d940b5a27eea9d1dab27 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:45:42 -0400 Subject: [PATCH 02/12] feat(web): replace bao section with progressive verified download demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StreamingBaoVerify` reset its playback state on every `file`-identity change, and `baoFile` (its input) updated every 4th auto-edit tick — so playback and the tamper dropdown got killed mid-use roughly every 2s. Same bug class as the tamper-slider snap-back. Replace it with `StreamingVerifiedDownload`: pressing Start snapshots the current file once (chunks, bao root, outboard), then streams it back as a sequence of bao slices, verifying each against the root as it lands. Verified bytes assemble into a buffer that paints the default PPM's canvas progressively, top row to bottom, so the image visibly fills in only from bytes that passed verification. A "Corrupt the connection" toggle flips a byte in every freshly-fetched slice; the verifier rejects it (visible red × on the chunk strip), then the retry arrives clean and painting resumes. Because the section now snapshots at Start instead of tracking the live file, no effect is keyed on `file` identity, and the recurring `bao_encode` pass (the demo's heaviest wasm call) no longer runs on a timer — it runs once, on click. This removes the `baoFile` / `baoTickRef` / `BAO_THROTTLE_TICKS` throttle machinery from `StreamingDemo` entirely. Measured (Node experiment against the built wasm pkg): | Input | chunks | outboard | proof/slice | bao_slice | bao_verify_slice | |-------------------|--------|-----------------------|---------------|------------|-------------------| | default ~768 KiB | 9 | 49,160 B (6.25%) | 1.9-13.9 KiB | ~0.1 ms | ~0.15 ms | | 16 MiB random | 197 | 1,048,520 B (6.25%) | 2.2-18.3 KiB | ~0.3 ms | ~0.1 ms | | 128 MiB (cap) | 1,576 | 8,388,552 B (6.25%) | 2.4-9.9 KiB | 3-11 ms | ~0.2 ms | | 19 B file | 1 | 8 B | 8 B | ~0 | ~0 | At the 128 MiB cap, the one-time Start cost is ~320 ms sync (`chunk_boundaries` 189 ms + `bao_encode` 133 ms) — acceptable in a click handler. No new tests: jsdom lacks canvas, so the parent `StreamingDemo` already can't mount under vitest; existing suites stay green. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/demos-tabs.tsx | 4 +- apps/web/src/components/streaming-demo.tsx | 349 +++++++++++++-------- 2 files changed, 222 insertions(+), 131 deletions(-) diff --git a/apps/web/src/components/demos-tabs.tsx b/apps/web/src/components/demos-tabs.tsx index b4eb0dac..6b10ba5e 100644 --- a/apps/web/src/components/demos-tabs.tsx +++ b/apps/web/src/components/demos-tabs.tsx @@ -73,8 +73,8 @@ const TABS: Tab[] = [ body: ( <> mkit cuts big files into content-defined chunks (FastCDC), ships only the chunks that changed, and verifies each - one as it arrives. git re-stores the whole binary on every edit. Watch the auto-editor run, or drop in your own - large file. + one as it arrives. git re-stores the whole binary on every edit. Watch the auto-editor run, start a verified + download, or drop in your own large file. ), Demo: StreamingDemo, diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index e025de70..afafafaf 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -22,18 +22,10 @@ const MAX_FILE_BYTES = 128 * 1024 * 1024 // `tickRunning` guard naturally throttles to whatever rate the wasm pass actually completes at on the host machine, // so a slow device falls back to "as fast as possible" instead of stacking ticks. const AUTO_EDIT_INTERVAL_MS = 500 -// Bao re-encode is the heaviest wasm pass in the demo (full outboard tree) — well above the chunker/blob/delta cost. -// Run it on a slower cadence (every Nth tick) so the chunker can stay snappy at 500 ms while the Bao section still -// re-roots often enough to feel live. ~2 s is below the threshold where the user notices a "stale" Bao root. -const BAO_THROTTLE_TICKS = 4 export function StreamingDemo() { const [currentFile, setCurrentFile] = useState(null) - // `baoFile` lags `currentFile` and only updates every `BAO_THROTTLE_TICKS` auto-edit ticks. Lets the heavy - // bao_encode pass run on a slower cadence than the chunker / ChunkedBlob / delta sections. - const [baoFile, setBaoFile] = useState(null) const [previousFile, setPreviousFile] = useState(null) - const baoTickRef = useRef(0) const [tooLarge, setTooLarge] = useState<{ name: string; size: number } | null>(null) const [autoEdit, setAutoEdit] = useState(true) // React strict-mode mounts twice; cache the generated default bytes so we don't burn the canvas pipeline twice on @@ -54,7 +46,6 @@ export function StreamingDemo() { const asset = generateDefaultPpm(DEFAULT_SEED, []) generatedRef.current = asset setCurrentFile(asset) - setBaoFile(asset) }, []) // Reject oversized files *before* reading the ArrayBuffer — saves the browser from allocating hundreds of MiB just @@ -69,11 +60,9 @@ export function StreamingDemo() { setTooLarge(null) setAutoEdit(false) overridesRef.current = [] - baoTickRef.current = 0 const next: FileAsset = { name, bytes: new Uint8Array(buf), source: 'upload' } setPreviousFile(currentFile) setCurrentFile(next) - setBaoFile(next) } // Auto-edit loop: snapshot the current file as the delta baseline once when toggled on, then mutate `currentFile` @@ -100,8 +89,6 @@ export function StreamingDemo() { : mutateRandomBytes(captured) if (cancelled) return setCurrentFile(next) - baoTickRef.current = (baoTickRef.current + 1) % BAO_THROTTLE_TICKS - if (baoTickRef.current === 0) setBaoFile(next) } finally { tickRunning = false } @@ -137,7 +124,7 @@ export function StreamingDemo() { - + ) @@ -447,126 +434,225 @@ function DeltaStat({ bytesOnWire, fullSize }: { bytesOnWire: number; fullSize: n ) } -// --- section 4: bao verify --------------------------------------------------- +// --- section 4: verified download --------------------------------------------- + +// Everything the stream needs, captured once when the user presses Start. Streaming a snapshot (not the live file) +// is what lets the auto-edit loop keep running underneath without resetting playback — the bug class the old +// section suffered from. `ppm` is non-null only for the default grid file (or a user-supplied raw PPM), and gates +// the progressive canvas paint; compressed uploads can't be painted from a byte prefix. +type DownloadSnapshot = { + bytes: Uint8Array + chunks: StripChunk[] + rootHex: string + outboard: Uint8Array + ppm: { width: number; height: number; pixelStart: number } | null +} -type StreamState = { - pending: Set +type DownloadState = { + phase: 'idle' | 'streaming' | 'done' + cursor: number // next chunk index to fetch + retryPending: boolean // chunk at `cursor` failed verification last tick; next fetch is the clean retry verified: Set - failed: Set - cursor: number + verifiedBytes: number // payload bytes that passed verification + wireBytes: number // everything sent: payloads + proofs, including rejected slices + wastedBytes: number // slices that failed verification and were thrown away + rejected: number // count of rejected slices +} + +const DOWNLOAD_IDLE: DownloadState = { + phase: 'idle', + cursor: 0, + retryPending: false, + verified: new Set(), + verifiedBytes: 0, + wireBytes: 0, + wastedBytes: 0, + rejected: 0, } -const EMPTY_STREAM: StreamState = { pending: new Set(), verified: new Set(), failed: new Set(), cursor: 0 } -function StreamingBaoVerify({ file }: { file: FileAsset }) { +// Nominal stream length regardless of chunk count: delay between ticks is clamp(6000 / chunkCount, 100, 600) ms and +// chunks fetched per tick is max(1, ceil(chunkCount / 60)). The floor keeps huge uploads from taking minutes; the +// ceiling keeps the 9-chunk default slow enough to actually watch chunks light up one at a time. +const STREAM_TARGET_MS = 6000 + +function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const api = useMkit() - const baoData = useMemo(() => { - const enc = api.bao_encode(file.bytes) - const r = api.chunk_boundaries(file.bytes) + const [snapshot, setSnapshot] = useState(null) + const [dl, setDl] = useState(DOWNLOAD_IDLE) + const [corrupt, setCorrupt] = useState(false) + const corruptRef = useRef(corrupt) // read by the tick so toggling mid-stream applies to the next fetch + corruptRef.current = corrupt + // Verified payload bytes assembled at their file offsets — the paint source. Allocated only for PPM snapshots. + const assembledRef = useRef(null) + const canvasRef = useRef(null) + // Rows already blitted to the canvas, so a tick only draws the newly-completed span instead of repainting from + // scratch. + const paintedRowsRef = useRef(0) + + // Clear the canvas to the same placeholder fill `FilePreview` uses for undecodable bytes, once per fresh snapshot. + // Keyed on `snapshot` (a new object every Start), never on `file` — see the tick effect below for why that split + // matters. + useEffect(() => { + if (!snapshot?.ppm) return + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + ctx.fillStyle = 'rgba(0,0,0,0.04)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + }, [snapshot]) + + // No effect keyed on `file`. The live file drifting under a running stream is by design — the stream downloads a + // snapshot taken at Start, and the copy below says so. + const start = () => { + const bytes = file.bytes // FileAsset bytes are replaced, never mutated, per tick — holding the reference is safe + const r = api.chunk_boundaries(bytes) const chunks: StripChunk[] = Array.from({ length: r.chunk_count }, (_, i) => { const c = r.chunk(i)! return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } }) - return { - hashHex: enc.hash_hex, - outboard: enc.outboard, - chunks, - bytesLen: file.bytes.byteLength, - } - }, [api, file]) + const enc = api.bao_encode(bytes) + const ppm = decodePpmHeader(bytes) + assembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null + paintedRowsRef.current = 0 + setSnapshot({ bytes, chunks, rootHex: enc.hash_hex, outboard: enc.outboard, ppm }) + setDl({ ...DOWNLOAD_IDLE, phase: 'streaming', verified: new Set() }) + } - const [streamState, setStreamState] = useState(EMPTY_STREAM) - const [tamperedIndex, setTamperedIndex] = useState(null) - const [playing, setPlaying] = useState(false) + const reset = () => { + setDl(DOWNLOAD_IDLE) + setSnapshot(null) + assembledRef.current = null + paintedRowsRef.current = 0 + } - // Reset state when the file changes. + // The tick: fetch `perTick` chunks as bao slices and verify each against the root as it lands. Sequential and + // single-flight by construction — the timeout only re-arms once `setDl` has landed a new `dl`, so there's no + // stacking even if a tick runs long (the 128 MiB cap can take longer than `delayMs`; see the guide's measured + // numbers). Corruption (when armed) hits only the first attempt at a chunk; the retry that follows a rejection + // always arrives clean, telling the "different mirror" story the toggle's helper copy promises. useEffect(() => { - setStreamState(EMPTY_STREAM) - setTamperedIndex(null) - setPlaying(false) - }, [file]) - - // Stream tick: once playing, advance one chunk every 200 ms. Pull the slice - // from the outboard, optionally corrupt one byte if we're at the tampered - // index, then verify. - useEffect(() => { - if (!playing) return - if (streamState.cursor >= baoData.chunks.length) { - setPlaying(false) - return - } - const idx = streamState.cursor - const chunk = baoData.chunks[idx]! - setStreamState((s) => ({ - ...s, - pending: new Set(s.pending).add(idx), - })) + if (dl.phase !== 'streaming' || !snapshot) return + const delayMs = Math.max(100, Math.min(600, Math.round(STREAM_TARGET_MS / snapshot.chunks.length))) + const perTick = Math.max(1, Math.ceil(snapshot.chunks.length / 60)) const t = setTimeout(() => { - try { - const slice = api.bao_slice(baoData.outboard, file.bytes, chunk.offset, chunk.len) - const buf = new Uint8Array(slice) - if (tamperedIndex === idx && buf.length > 0) { - // Flip the last byte — Bao verifier reads slice format end-to-end so any payload mutation trips it. - buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 + const next: DownloadState = { ...dl, verified: new Set(dl.verified) } + let verifiedAny = false + for (let slot = 0; slot < perTick; slot++) { + if (next.cursor >= snapshot.chunks.length) { + next.phase = 'done' + break + } + const idx = next.cursor + const chunk = snapshot.chunks[idx]! + try { + const slice = api.bao_slice(snapshot.outboard, snapshot.bytes, chunk.offset, chunk.len) + next.wireBytes += slice.length + const isRetry = next.retryPending + const buf = new Uint8Array(slice) + // Corrupt fresh fetches only; a retry after a rejection arrives clean, as if from a different mirror. + if (!isRetry && corruptRef.current) buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 + const v = api.bao_verify_slice(snapshot.rootHex, buf, chunk.offset, chunk.len) + if (v.ok) { + if (assembledRef.current && v.bytes) assembledRef.current.set(v.bytes, chunk.offset) + next.verified.add(idx) + next.verifiedBytes += chunk.len + next.cursor += 1 + next.retryPending = false + verifiedAny = true + } else { + next.wastedBytes += slice.length + next.rejected += 1 + next.retryPending = true + break // the rejection consumes the rest of this tick — a visible stall at the corrupted chunk + } + } catch { + // Slice extraction can only throw on internal errors; treat it like a failed verification rather than + // letting it kill the stream. + next.rejected += 1 + next.retryPending = true + break } - const result = api.bao_verify_slice(baoData.hashHex, buf, chunk.offset, chunk.len) - setStreamState((s) => { - const pending = new Set(s.pending) - pending.delete(idx) - const verified = new Set(s.verified) - const failed = new Set(s.failed) - if (result.ok) verified.add(idx) - else failed.add(idx) - return { pending, verified, failed, cursor: s.cursor + 1 } - }) - } catch { - setStreamState((s) => { - const pending = new Set(s.pending) - pending.delete(idx) - const failed = new Set(s.failed) - failed.add(idx) - return { ...s, pending, failed, cursor: s.cursor + 1 } - }) } - }, 200) + + // Verified bytes form a contiguous prefix (the stream is sequential), so paint only the newly-completed rows. + if (snapshot.ppm && verifiedAny) { + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + const assembled = assembledRef.current + if (ctx && assembled) { + const { width, pixelStart } = snapshot.ppm + const prefixBytes = + next.cursor < snapshot.chunks.length ? snapshot.chunks[next.cursor]!.offset : snapshot.bytes.byteLength + const bytesPerRow = width * 3 + const rowsDone = Math.floor(Math.max(0, prefixBytes - pixelStart) / bytesPerRow) + const painted = paintedRowsRef.current + if (rowsDone > painted) { + const rowSpan = rowsDone - painted + const rgba = new Uint8ClampedArray(width * rowSpan * 4) + let p = pixelStart + painted * bytesPerRow + for (let i = 0; i < width * rowSpan; i++) { + rgba[i * 4] = assembled[p++]! + rgba[i * 4 + 1] = assembled[p++]! + rgba[i * 4 + 2] = assembled[p++]! + rgba[i * 4 + 3] = 255 + } + ctx.putImageData(new ImageData(rgba, width, rowSpan), 0, painted) + paintedRowsRef.current = rowsDone + } + } + } + + setDl(next) + }, delayMs) return () => clearTimeout(t) - }, [playing, streamState.cursor, baoData, api, file.bytes, tamperedIndex]) + }, [dl, snapshot, api]) - const start = () => { - setStreamState(EMPTY_STREAM) - setPlaying(true) - } - const reset = () => { - setPlaying(false) - setStreamState(EMPTY_STREAM) - } + const total = snapshot?.bytes.byteLength ?? 0 + const proofBytes = dl.wireBytes - dl.wastedBytes - dl.verifiedBytes + const startLabel = + dl.phase === 'streaming' ? 'Downloading…' : dl.phase === 'done' ? 'Download again' : 'Start download' return (
-

- Bao root: {baoData.hashHex.slice(0, 16)}… · outboard {formatBytes(baoData.outboard.length)} for a{' '} - {formatBytes(baoData.bytesLen)} file -

- + {snapshot ? ( +

+ Bao root: {snapshot.rootHex.slice(0, 16)}… · outboard {formatBytes(snapshot.outboard.length)} (~6% of the + file) · streams the file as it was at Start +

+ ) : null} + {snapshot?.ppm ? ( + + ) : null} + {snapshot ? ( + + ) : null}
- - {tamperedIndex !== null ? ( - - Will tamper chunk {tamperedIndex} - - ) : null} +
-

- Verified {streamState.verified.size} · failed {streamState.failed.size} · {baoData.chunks.length} total -

+ {corrupt ? ( +

+ Every fresh chunk arrives tampered; the verifier rejects it and the re-fetch comes in clean. +

+ ) : null} + {snapshot ? ( +

+ Verified {formatBytes(dl.verifiedBytes)} of {formatBytes(total)} · {formatBytes(dl.wireBytes)} on the wire ·{' '} + {formatBytes(proofBytes)} proof · {dl.rejected} rejected ({formatBytes(dl.wastedBytes)} wasted) +

+ ) : ( +

+ Press Start download to stream the current file back, verifying every chunk against its Bao root. +

+ )} + {dl.phase === 'done' ? ( +

Complete — every byte verified before it was shown.

+ ) : null}
) } From 21970c5b94417a0ee84038b1a172b73c61b1826e Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:54:50 -0400 Subject: [PATCH 03/12] refactor(web): split pure PPM helpers out of streaming-demo, dedupe chunk mapping Extracts the pure byte/format helpers into apps/web/src/lib/ppm.ts, dedupes the five copies of the chunk-mapping loop behind a stripChunks helper, and replaces the DOWNLOAD_IDLE shared constant with a freshDownloadState() factory. Addresses the code-quality review findings on #886. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 277 +++------------------ apps/web/src/lib/ppm.ts | 213 ++++++++++++++++ 2 files changed, 246 insertions(+), 244 deletions(-) create mode 100644 apps/web/src/lib/ppm.ts diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index afafafaf..d92c7546 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -1,16 +1,22 @@ 'use client' import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { mulberry32 } from '../lib/grid-svg' +import { type FileAsset, decodePpmHeader, driftDefaultPpm, generateDefaultPpm, mutateRandomBytes } from '../lib/ppm' import { ChunkStrip, type StripChunk } from './chunk-strip' import { ObjectRow } from './result-panel' import { formatBytes, useMkit } from './use-mkit' -// `source` lets the auto-edit loop pick the right mutator: defaults regenerate via canvas+PPM so a single grid-cell -// edit produces a single localised byte-range change, uploads get random byte flips. -type FileAsset = { name: string; bytes: Uint8Array; source: 'default' | 'upload' } +// The wasm results expose their chunk lists via an index getter; materialise once into the shape ChunkStrip renders. +function stripChunks(r: { + chunk_count: number + chunk(i: number): { offset: number; len: number; hash_hex: string } | undefined +}): StripChunk[] { + return Array.from({ length: r.chunk_count }, (_, i) => { + const c = r.chunk(i)! + return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } + }) +} -const DEFAULT_NAME = 'grid.ppm' const DEFAULT_SEED = 0xc0de_cafe // Demo-only hard cap. At 64 KiB avg FastCDC chunks, 128 MiB → ~2,048 chunks — the strip stays readable (each chip is // still visible at typical viewport widths) and the wasm passes finish in a few seconds on a modest laptop. Above this @@ -33,7 +39,7 @@ export function StreamingDemo() { const generatedRef = useRef(null) // Accumulated cell-hue overrides for the default PPM so each auto-edit tick adds one drifted square on top of the // running mutation history, instead of replacing the whole image. Reset whenever the file is replaced. - const overridesRef = useRef([]) + const overridesRef = useRef[1]>([]) // Mirror of `currentFile` so the auto-edit interval can read the latest bytes without re-binding on every render. const currentFileRef = useRef(null) currentFileRef.current = currentFile @@ -244,10 +250,7 @@ function StreamingChunker({ file }: { file: FileAsset }) { const api = useMkit() const result = useMemo(() => { const r = api.chunk_boundaries(file.bytes) - const chunks: StripChunk[] = Array.from({ length: r.chunk_count }, (_, i) => { - const c = r.chunk(i)! - return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } - }) + const chunks = stripChunks(r) return { chunks, avg: r.avg, min: r.min, max: r.max, count: r.chunk_count } }, [api, file]) @@ -308,10 +311,7 @@ function StreamingChunkedBlob({ file }: { file: FileAsset }) { const api = useMkit() const blob = useMemo(() => { const r = api.chunked_blob_encode(file.bytes) - const chunks: StripChunk[] = Array.from({ length: r.chunk_count }, (_, i) => { - const c = r.chunk(i)! - return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } - }) + const chunks = stripChunks(r) return { rootHash: r.root_hash_hex, bytesLen: r.bytes_len, count: r.chunk_count, chunks } }, [api, file]) @@ -345,14 +345,8 @@ function StreamingDelta({ current, previous }: { current: FileAsset; previous: F if (!previous) return null const prev = api.chunk_boundaries(previous.bytes) const curr = api.chunk_boundaries(current.bytes) - const prevChunks: StripChunk[] = Array.from({ length: prev.chunk_count }, (_, i) => { - const c = prev.chunk(i)! - return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } - }) - const currChunks: StripChunk[] = Array.from({ length: curr.chunk_count }, (_, i) => { - const c = curr.chunk(i)! - return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } - }) + const prevChunks = stripChunks(prev) + const currChunks = stripChunks(curr) const prevHashes = new Set(prevChunks.map((c) => c.hash_hex)) const currHashes = new Set(currChunks.map((c) => c.hash_hex)) const prevDim = new Set() @@ -459,15 +453,19 @@ type DownloadState = { rejected: number // count of rejected slices } -const DOWNLOAD_IDLE: DownloadState = { - phase: 'idle', - cursor: 0, - retryPending: false, - verified: new Set(), - verifiedBytes: 0, - wireBytes: 0, - wastedBytes: 0, - rejected: 0, +// Factory, not a shared constant: DownloadState holds a Set, and a module-level instance would alias the same Set +// into every reset. Every caller gets fresh state. +function freshDownloadState(): DownloadState { + return { + phase: 'idle', + cursor: 0, + retryPending: false, + verified: new Set(), + verifiedBytes: 0, + wireBytes: 0, + wastedBytes: 0, + rejected: 0, + } } // Nominal stream length regardless of chunk count: delay between ticks is clamp(6000 / chunkCount, 100, 600) ms and @@ -478,7 +476,7 @@ const STREAM_TARGET_MS = 6000 function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const api = useMkit() const [snapshot, setSnapshot] = useState(null) - const [dl, setDl] = useState(DOWNLOAD_IDLE) + const [dl, setDl] = useState(freshDownloadState) const [corrupt, setCorrupt] = useState(false) const corruptRef = useRef(corrupt) // read by the tick so toggling mid-stream applies to the next fetch corruptRef.current = corrupt @@ -506,20 +504,17 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const start = () => { const bytes = file.bytes // FileAsset bytes are replaced, never mutated, per tick — holding the reference is safe const r = api.chunk_boundaries(bytes) - const chunks: StripChunk[] = Array.from({ length: r.chunk_count }, (_, i) => { - const c = r.chunk(i)! - return { offset: c.offset, len: c.len, hash_hex: c.hash_hex } - }) + const chunks = stripChunks(r) const enc = api.bao_encode(bytes) const ppm = decodePpmHeader(bytes) assembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null paintedRowsRef.current = 0 setSnapshot({ bytes, chunks, rootHex: enc.hash_hex, outboard: enc.outboard, ppm }) - setDl({ ...DOWNLOAD_IDLE, phase: 'streaming', verified: new Set() }) + setDl({ ...freshDownloadState(), phase: 'streaming' }) } const reset = () => { - setDl(DOWNLOAD_IDLE) + setDl(freshDownloadState()) setSnapshot(null) assembledRef.current = null paintedRowsRef.current = 0 @@ -726,130 +721,6 @@ function findChunkAtOffset(chunks: StripChunk[], offset: number): number | null return chunks.length > 0 ? chunks.length - 1 : null } -// Build a 512×512 PPM (NetPBM P6) from a deterministic mulberry32 noise grid (256×256 cells × 2 px each), with -// optional hue overrides on specific cells. PPM rather than PNG so a single-cell edit produces a contiguous -// byte-range change (~12 bytes per cell at 2px×2px×3bytes) instead of zlib-cascading the change across the entire -// compressed stream — which is the whole point of demonstrating content-defined chunking. PPM picked over BMP for -// the smaller, format-agnostic header (`P6 width height 255` in ASCII) and natural RGB byte order. -type CellOverride = { x: number; y: number; hue: number } - -// 512×512 raster (256 cells × 2 px) keeps ~12 FastCDC chunks (above the 4-chunk min that makes the strip readable) -// while cutting wasm bandwidth-bound work — chunker, ChunkedBlob, delta, Bao — by 2.25× vs the prior 768-pixel grid. -// Per-tick wasm budget drops below ~70 ms which restores 500 ms cadence with main-thread headroom. -const GRID_CELLS = 256 -const GRID_CELL_PX = 2 -const GRID_SIZE = GRID_CELLS * GRID_CELL_PX - -// Module-scoped cache of the unmutated baseline RGBA grid, keyed by seed. The 65k `fillRect` baseline draw is the -// dominant cost in the per-tick auto-edit loop; once we've computed the base for a given seed, every subsequent drift -// tick just copies this buffer and writes the override pixels directly into the copy. Reset the cache by reloading -// the page (the seed never changes during a session). -const baselineCache = new Map() - -function buildBaseline(seed: number): Uint8ClampedArray { - const cached = baselineCache.get(seed) - if (cached) return cached - const canvas = document.createElement('canvas') - canvas.width = GRID_SIZE - canvas.height = GRID_SIZE - const ctx = canvas.getContext('2d') - if (!ctx) throw new Error('canvas 2d unavailable') - const rand = mulberry32(seed) - for (let y = 0; y < GRID_CELLS; y++) { - for (let x = 0; x < GRID_CELLS; x++) { - const hue = Math.floor(rand() * 360) - ctx.fillStyle = `hsl(${hue} 70% 60%)` - ctx.fillRect(x * GRID_CELL_PX, y * GRID_CELL_PX, GRID_CELL_PX, GRID_CELL_PX) - } - } - const data = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE).data - baselineCache.set(seed, data) - return data -} - -// Resolve `hsl(h 70% 60%)` to packed RGBA (4 bytes) once per override so the per-pixel write loop is straight integer -// stores rather than a string parse + canvas state shuffle. -function hueToRgba(hue: number): [number, number, number] { - // HSL → RGB at S=70%, L=60%. Math straight from the CSS-color spec. - const s = 0.7 - const l = 0.6 - const c = (1 - Math.abs(2 * l - 1)) * s - const hp = (((hue % 360) + 360) % 360) / 60 - const x = c * (1 - Math.abs((hp % 2) - 1)) - let r1 = 0 - let g1 = 0 - let b1 = 0 - if (hp < 1) { - r1 = c - g1 = x - } else if (hp < 2) { - r1 = x - g1 = c - } else if (hp < 3) { - g1 = c - b1 = x - } else if (hp < 4) { - g1 = x - b1 = c - } else if (hp < 5) { - r1 = x - b1 = c - } else { - r1 = c - b1 = x - } - const m = l - c / 2 - return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)] -} - -function generateDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { - const base = buildBaseline(seed) - const data = new Uint8ClampedArray(base) // copy so mutations don't pollute the cached baseline - for (const o of overrides) { - const [r, g, b] = hueToRgba(o.hue) - const x0 = o.x * GRID_CELL_PX - const y0 = o.y * GRID_CELL_PX - for (let dy = 0; dy < GRID_CELL_PX; dy++) { - for (let dx = 0; dx < GRID_CELL_PX; dx++) { - const i = ((y0 + dy) * GRID_SIZE + (x0 + dx)) * 4 - data[i] = r - data[i + 1] = g - data[i + 2] = b - // alpha stays 255 from the canvas baseline - } - } - } - const bytes = encodePpm(data, GRID_SIZE, GRID_SIZE) - return { name: DEFAULT_NAME, bytes, source: 'default' } -} - -// Push one new override onto the running list and re-render. Cell coordinate is uniformly random across the grid. -function driftDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { - overrides.push({ - x: Math.floor(Math.random() * GRID_CELLS), - y: Math.floor(Math.random() * GRID_CELLS), - hue: Math.floor(Math.random() * 360), - }) - return generateDefaultPpm(seed, overrides) -} - -// Flip 1–3 bytes outside any recognised image header. Header preservation keeps a real PNG/JPEG/PPM upload still -// readable as that format if the user opens it elsewhere; the chunker doesn't care either way but the courtesy -// matters when a user drops in their own file. -function mutateRandomBytes(asset: FileAsset): FileAsset { - const bytes = new Uint8Array(asset.bytes) - const headerSkip = detectHeaderSize(bytes) - const range = bytes.length - headerSkip - if (range <= 0) return asset - const flips = 1 + Math.floor(Math.random() * 3) - for (let i = 0; i < flips; i++) { - const offset = headerSkip + Math.floor(Math.random() * range) - // XOR with a non-zero mask so the byte definitely changes. - bytes[offset] = ((bytes[offset] ?? 0) ^ (1 + Math.floor(Math.random() * 255))) & 0xff - } - return { ...asset, bytes } -} - // 96 px square preview of the current file. PPM is parsed and blitted directly via putImageData on an offscreen // canvas, then downscaled with `drawImage`; everything else is rendered through a Blob URL `` decode. Both paths // fall back to a hairline placeholder if decoding fails (e.g. non-image upload). Small enough to redraw on every @@ -932,85 +803,3 @@ function FilePreview({ asset }: { asset: FileAsset }) { /> ) } - -// Walk the three ASCII whitespace-separated tokens after the `P6` magic, returning width/height and the byte offset -// where pixel data starts. Tolerates `#` comments per the spec. Returns null on anything else. -function decodePpmHeader(bytes: Uint8Array): { width: number; height: number; pixelStart: number } | null { - if (bytes.length < 11 || bytes[0] !== 0x50 || bytes[1] !== 0x36) return null - let p = 2 - const readToken = (): string | null => { - // Skip whitespace and `#`-prefixed comment lines. - while (p < bytes.length) { - const b = bytes[p]! - if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) { - p++ - continue - } - if (b === 0x23) { - while (p < bytes.length && bytes[p] !== 0x0a) p++ - continue - } - break - } - const start = p - while (p < bytes.length) { - const b = bytes[p]! - if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) break - p++ - } - if (p === start) return null - return new TextDecoder().decode(bytes.subarray(start, p)) - } - const widthTok = readToken() - const heightTok = readToken() - const maxvalTok = readToken() - if (!widthTok || !heightTok || !maxvalTok) return null - // Spec: exactly one whitespace char follows maxval before the binary pixel block starts. - if (p < bytes.length) p++ - const width = Number(widthTok) - const height = Number(heightTok) - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null - if (bytes.length < p + width * height * 3) return null - return { width, height, pixelStart: p } -} - -function detectHeaderSize(bytes: Uint8Array): number { - if (bytes.length < 8) return 0 - // PNG: 89 50 4E 47 0D 0A 1A 0A - if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 8 - // PPM (P6): "P6\n \n255\n" — three ASCII lines, length varies. Walk until we've passed three - // newlines so a random byte flip lands strictly in the pixel body. - if (bytes[0] === 0x50 && bytes[1] === 0x36) { - let nl = 0 - for (let i = 2; i < Math.min(bytes.length, 64); i++) { - if (bytes[i] === 0x0a) { - nl++ - if (nl === 3) return i + 1 - } - } - return 0 - } - // JPEG: FF D8 ... start; conservative skip of first 4 bytes (SOI + first marker) - if (bytes[0] === 0xff && bytes[1] === 0xd8) return 4 - return 0 -} - -// Encode RGBA pixels to a NetPBM P6 (binary PPM) blob. Header is three ASCII lines — magic `P6`, dimensions, and -// max-component value 255 — followed by raw RGB triplets in scanline order. No row padding, no endian-flipped bytes, -// no quirks. ~15-byte header for our 512×512 grid. -function encodePpm(rgba: Uint8ClampedArray, width: number, height: number): Uint8Array { - const headerStr = `P6\n${width} ${height}\n255\n` - const headerBytes = new TextEncoder().encode(headerStr) - const pixelBytes = width * height * 3 - const out = new Uint8Array(headerBytes.length + pixelBytes) - out.set(headerBytes, 0) - let p = headerBytes.length - const total = width * height - for (let i = 0; i < total; i++) { - const j = i * 4 - out[p++] = rgba[j]! - out[p++] = rgba[j + 1]! - out[p++] = rgba[j + 2]! - } - return out -} diff --git a/apps/web/src/lib/ppm.ts b/apps/web/src/lib/ppm.ts new file mode 100644 index 00000000..22f54761 --- /dev/null +++ b/apps/web/src/lib/ppm.ts @@ -0,0 +1,213 @@ +import { mulberry32 } from './grid-svg' + +// `source` lets the auto-edit loop pick the right mutator: defaults regenerate via canvas+PPM so a single grid-cell +// edit produces a single localised byte-range change, uploads get random byte flips. +export type FileAsset = { name: string; bytes: Uint8Array; source: 'default' | 'upload' } + +const DEFAULT_NAME = 'grid.ppm' + +// Build a 512×512 PPM (NetPBM P6) from a deterministic mulberry32 noise grid (256×256 cells × 2 px each), with +// optional hue overrides on specific cells. PPM rather than PNG so a single-cell edit produces a contiguous +// byte-range change (~12 bytes per cell at 2px×2px×3bytes) instead of zlib-cascading the change across the entire +// compressed stream — which is the whole point of demonstrating content-defined chunking. PPM picked over BMP for +// the smaller, format-agnostic header (`P6 width height 255` in ASCII) and natural RGB byte order. +type CellOverride = { x: number; y: number; hue: number } + +// 512×512 raster (256 cells × 2 px) keeps ~12 FastCDC chunks (above the 4-chunk min that makes the strip readable) +// while cutting wasm bandwidth-bound work — chunker, ChunkedBlob, delta, Bao — by 2.25× vs the prior 768-pixel grid. +// Per-tick wasm budget drops below ~70 ms which restores 500 ms cadence with main-thread headroom. +const GRID_CELLS = 256 +const GRID_CELL_PX = 2 +const GRID_SIZE = GRID_CELLS * GRID_CELL_PX + +// Module-scoped cache of the unmutated baseline RGBA grid, keyed by seed. The 65k `fillRect` baseline draw is the +// dominant cost in the per-tick auto-edit loop; once we've computed the base for a given seed, every subsequent drift +// tick just copies this buffer and writes the override pixels directly into the copy. Reset the cache by reloading +// the page (the seed never changes during a session). +const baselineCache = new Map() + +function buildBaseline(seed: number): Uint8ClampedArray { + const cached = baselineCache.get(seed) + if (cached) return cached + const canvas = document.createElement('canvas') + canvas.width = GRID_SIZE + canvas.height = GRID_SIZE + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('canvas 2d unavailable') + const rand = mulberry32(seed) + for (let y = 0; y < GRID_CELLS; y++) { + for (let x = 0; x < GRID_CELLS; x++) { + const hue = Math.floor(rand() * 360) + ctx.fillStyle = `hsl(${hue} 70% 60%)` + ctx.fillRect(x * GRID_CELL_PX, y * GRID_CELL_PX, GRID_CELL_PX, GRID_CELL_PX) + } + } + const data = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE).data + baselineCache.set(seed, data) + return data +} + +// Resolve `hsl(h 70% 60%)` to packed RGBA (4 bytes) once per override so the per-pixel write loop is straight integer +// stores rather than a string parse + canvas state shuffle. +function hueToRgba(hue: number): [number, number, number] { + // HSL → RGB at S=70%, L=60%. Math straight from the CSS-color spec. + const s = 0.7 + const l = 0.6 + const c = (1 - Math.abs(2 * l - 1)) * s + const hp = (((hue % 360) + 360) % 360) / 60 + const x = c * (1 - Math.abs((hp % 2) - 1)) + let r1 = 0 + let g1 = 0 + let b1 = 0 + if (hp < 1) { + r1 = c + g1 = x + } else if (hp < 2) { + r1 = x + g1 = c + } else if (hp < 3) { + g1 = c + b1 = x + } else if (hp < 4) { + g1 = x + b1 = c + } else if (hp < 5) { + r1 = x + b1 = c + } else { + r1 = c + b1 = x + } + const m = l - c / 2 + return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)] +} + +export function generateDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { + const base = buildBaseline(seed) + const data = new Uint8ClampedArray(base) // copy so mutations don't pollute the cached baseline + for (const o of overrides) { + const [r, g, b] = hueToRgba(o.hue) + const x0 = o.x * GRID_CELL_PX + const y0 = o.y * GRID_CELL_PX + for (let dy = 0; dy < GRID_CELL_PX; dy++) { + for (let dx = 0; dx < GRID_CELL_PX; dx++) { + const i = ((y0 + dy) * GRID_SIZE + (x0 + dx)) * 4 + data[i] = r + data[i + 1] = g + data[i + 2] = b + // alpha stays 255 from the canvas baseline + } + } + } + const bytes = encodePpm(data, GRID_SIZE, GRID_SIZE) + return { name: DEFAULT_NAME, bytes, source: 'default' } +} + +// Push one new override onto the running list and re-render. Cell coordinate is uniformly random across the grid. +export function driftDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { + overrides.push({ + x: Math.floor(Math.random() * GRID_CELLS), + y: Math.floor(Math.random() * GRID_CELLS), + hue: Math.floor(Math.random() * 360), + }) + return generateDefaultPpm(seed, overrides) +} + +// Flip 1–3 bytes outside any recognised image header. Header preservation keeps a real PNG/JPEG/PPM upload still +// readable as that format if the user opens it elsewhere; the chunker doesn't care either way but the courtesy +// matters when a user drops in their own file. +export function mutateRandomBytes(asset: FileAsset): FileAsset { + const bytes = new Uint8Array(asset.bytes) + const headerSkip = detectHeaderSize(bytes) + const range = bytes.length - headerSkip + if (range <= 0) return asset + const flips = 1 + Math.floor(Math.random() * 3) + for (let i = 0; i < flips; i++) { + const offset = headerSkip + Math.floor(Math.random() * range) + // XOR with a non-zero mask so the byte definitely changes. + bytes[offset] = ((bytes[offset] ?? 0) ^ (1 + Math.floor(Math.random() * 255))) & 0xff + } + return { ...asset, bytes } +} + +// Walk the three ASCII whitespace-separated tokens after the `P6` magic, returning width/height and the byte offset +// where pixel data starts. Tolerates `#` comments per the spec. Returns null on anything else. +export function decodePpmHeader(bytes: Uint8Array): { width: number; height: number; pixelStart: number } | null { + if (bytes.length < 11 || bytes[0] !== 0x50 || bytes[1] !== 0x36) return null + let p = 2 + const readToken = (): string | null => { + // Skip whitespace and `#`-prefixed comment lines. + while (p < bytes.length) { + const b = bytes[p]! + if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) { + p++ + continue + } + if (b === 0x23) { + while (p < bytes.length && bytes[p] !== 0x0a) p++ + continue + } + break + } + const start = p + while (p < bytes.length) { + const b = bytes[p]! + if (b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) break + p++ + } + if (p === start) return null + return new TextDecoder().decode(bytes.subarray(start, p)) + } + const widthTok = readToken() + const heightTok = readToken() + const maxvalTok = readToken() + if (!widthTok || !heightTok || !maxvalTok) return null + // Spec: exactly one whitespace char follows maxval before the binary pixel block starts. + if (p < bytes.length) p++ + const width = Number(widthTok) + const height = Number(heightTok) + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null + if (bytes.length < p + width * height * 3) return null + return { width, height, pixelStart: p } +} + +function detectHeaderSize(bytes: Uint8Array): number { + if (bytes.length < 8) return 0 + // PNG: 89 50 4E 47 0D 0A 1A 0A + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 8 + // PPM (P6): "P6\n \n255\n" — three ASCII lines, length varies. Walk until we've passed three + // newlines so a random byte flip lands strictly in the pixel body. + if (bytes[0] === 0x50 && bytes[1] === 0x36) { + let nl = 0 + for (let i = 2; i < Math.min(bytes.length, 64); i++) { + if (bytes[i] === 0x0a) { + nl++ + if (nl === 3) return i + 1 + } + } + return 0 + } + // JPEG: FF D8 ... start; conservative skip of first 4 bytes (SOI + first marker) + if (bytes[0] === 0xff && bytes[1] === 0xd8) return 4 + return 0 +} + +// Encode RGBA pixels to a NetPBM P6 (binary PPM) blob. Header is three ASCII lines — magic `P6`, dimensions, and +// max-component value 255 — followed by raw RGB triplets in scanline order. No row padding, no endian-flipped bytes, +// no quirks. ~15-byte header for our 512×512 grid. +function encodePpm(rgba: Uint8ClampedArray, width: number, height: number): Uint8Array { + const headerStr = `P6\n${width} ${height}\n255\n` + const headerBytes = new TextEncoder().encode(headerStr) + const pixelBytes = width * height * 3 + const out = new Uint8Array(headerBytes.length + pixelBytes) + out.set(headerBytes, 0) + let p = headerBytes.length + const total = width * height + for (let i = 0; i < total; i++) { + const j = i * 4 + out[p++] = rgba[j]! + out[p++] = rgba[j + 1]! + out[p++] = rgba[j + 2]! + } + return out +} From a040d879c7eb6799b87fd004d57b4b2fd6263385 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:56:04 -0400 Subject: [PATCH 04/12] refactor(web): export CellOverride instead of deriving it via Parameters<> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overridesRef type in streaming-demo.tsx leaned on Parameters[1] to avoid exporting the type — indirection where a one-line type export is the direct contract. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 11 +++++++++-- apps/web/src/lib/ppm.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index d92c7546..df08dabf 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -1,7 +1,14 @@ 'use client' import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { type FileAsset, decodePpmHeader, driftDefaultPpm, generateDefaultPpm, mutateRandomBytes } from '../lib/ppm' +import { + type CellOverride, + type FileAsset, + decodePpmHeader, + driftDefaultPpm, + generateDefaultPpm, + mutateRandomBytes, +} from '../lib/ppm' import { ChunkStrip, type StripChunk } from './chunk-strip' import { ObjectRow } from './result-panel' import { formatBytes, useMkit } from './use-mkit' @@ -39,7 +46,7 @@ export function StreamingDemo() { const generatedRef = useRef(null) // Accumulated cell-hue overrides for the default PPM so each auto-edit tick adds one drifted square on top of the // running mutation history, instead of replacing the whole image. Reset whenever the file is replaced. - const overridesRef = useRef[1]>([]) + const overridesRef = useRef([]) // Mirror of `currentFile` so the auto-edit interval can read the latest bytes without re-binding on every render. const currentFileRef = useRef(null) currentFileRef.current = currentFile diff --git a/apps/web/src/lib/ppm.ts b/apps/web/src/lib/ppm.ts index 22f54761..982b10d8 100644 --- a/apps/web/src/lib/ppm.ts +++ b/apps/web/src/lib/ppm.ts @@ -11,7 +11,7 @@ const DEFAULT_NAME = 'grid.ppm' // byte-range change (~12 bytes per cell at 2px×2px×3bytes) instead of zlib-cascading the change across the entire // compressed stream — which is the whole point of demonstrating content-defined chunking. PPM picked over BMP for // the smaller, format-agnostic header (`P6 width height 255` in ASCII) and natural RGB byte order. -type CellOverride = { x: number; y: number; hue: number } +export type CellOverride = { x: number; y: number; hue: number } // 512×512 raster (256 cells × 2 px) keeps ~12 FastCDC chunks (above the 4-chunk min that makes the strip readable) // while cutting wasm bandwidth-bound work — chunker, ChunkedBlob, delta, Bao — by 2.25× vs the prior 768-pixel grid. From bb45c70f1a504230755478fe0e1655065fe009c0 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:51:28 -0400 Subject: [PATCH 05/12] feat(web): make the verified download the whole streaming demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erica's direction: the old chunker/blob/delta sections and the auto-edit loop are gone — the progressive verified download is now the entire streaming demo. This also fixes a measured perf stall: the auto-edit loop plus the old sections saturated the main thread with ~1.7s long tasks in dev, starving the stream timer down to ~3.4s/chunk; with them removed the designed ~600ms cadence is restored. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/demos-tabs.tsx | 6 +- apps/web/src/components/streaming-demo.tsx | 336 ++------------------- apps/web/src/lib/ppm.ts | 135 +-------- 3 files changed, 43 insertions(+), 434 deletions(-) diff --git a/apps/web/src/components/demos-tabs.tsx b/apps/web/src/components/demos-tabs.tsx index 6b10ba5e..b29fa04f 100644 --- a/apps/web/src/components/demos-tabs.tsx +++ b/apps/web/src/components/demos-tabs.tsx @@ -72,9 +72,9 @@ const TABS: Tab[] = [ blurb: 'Content-defined chunking ships and verifies only the parts of a file that changed.', body: ( <> - mkit cuts big files into content-defined chunks (FastCDC), ships only the chunks that changed, and verifies each - one as it arrives. git re-stores the whole binary on every edit. Watch the auto-editor run, start a verified - download, or drop in your own large file. + mkit cuts big files into content-defined chunks (FastCDC) and verifies each one against a Bao root as it arrives + — corruption is caught mid-stream and re-fetched, not discovered after the download. Watch the file stream in + verified, corrupt the connection, or drop in your own large file. ), Demo: StreamingDemo, diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index df08dabf..342fe4d3 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -1,16 +1,8 @@ 'use client' -import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' -import { - type CellOverride, - type FileAsset, - decodePpmHeader, - driftDefaultPpm, - generateDefaultPpm, - mutateRandomBytes, -} from '../lib/ppm' +import { useEffect, useRef, useState } from 'react' +import { type FileAsset, decodePpmHeader, generateDefaultPpm } from '../lib/ppm' import { ChunkStrip, type StripChunk } from './chunk-strip' -import { ObjectRow } from './result-panel' import { formatBytes, useMkit } from './use-mkit' // The wasm results expose their chunk lists via an index getter; materialise once into the shape ChunkStrip renders. @@ -31,32 +23,20 @@ const DEFAULT_SEED = 0xc0de_cafe // state, Bao outboard) and start freezing the tab while building tens of thousands of DOM nodes. Real mkit has no // such limit; the cap only applies to this interactive page. const MAX_FILE_BYTES = 128 * 1024 * 1024 -// Auto-edit cadence. 500 ms is the visible target — the user perceives a continuously changing chunker. The -// `tickRunning` guard naturally throttles to whatever rate the wasm pass actually completes at on the host machine, -// so a slow device falls back to "as fast as possible" instead of stacking ticks. -const AUTO_EDIT_INTERVAL_MS = 500 export function StreamingDemo() { const [currentFile, setCurrentFile] = useState(null) - const [previousFile, setPreviousFile] = useState(null) const [tooLarge, setTooLarge] = useState<{ name: string; size: number } | null>(null) - const [autoEdit, setAutoEdit] = useState(true) // React strict-mode mounts twice; cache the generated default bytes so we don't burn the canvas pipeline twice on // first paint. Ref survives the remount. const generatedRef = useRef(null) - // Accumulated cell-hue overrides for the default PPM so each auto-edit tick adds one drifted square on top of the - // running mutation history, instead of replacing the whole image. Reset whenever the file is replaced. - const overridesRef = useRef([]) - // Mirror of `currentFile` so the auto-edit interval can read the latest bytes without re-binding on every render. - const currentFileRef = useRef(null) - currentFileRef.current = currentFile useEffect(() => { if (generatedRef.current) { setCurrentFile(generatedRef.current) return } - const asset = generateDefaultPpm(DEFAULT_SEED, []) + const asset = generateDefaultPpm(DEFAULT_SEED) generatedRef.current = asset setCurrentFile(asset) }, []) @@ -71,72 +51,22 @@ export function StreamingDemo() { } const buf = await file.arrayBuffer() setTooLarge(null) - setAutoEdit(false) - overridesRef.current = [] const next: FileAsset = { name, bytes: new Uint8Array(buf), source: 'upload' } - setPreviousFile(currentFile) setCurrentFile(next) } - // Auto-edit loop: snapshot the current file as the delta baseline once when toggled on, then mutate `currentFile` - // every tick. Default file → grow `overridesRef` and re-render the PPM (localised byte-range change). Uploaded file - // → flip 1–3 random bytes outside any image header. The interval reads the latest file via the functional updater - // so we don't restart it on every state change. - useEffect(() => { - if (!autoEdit) return - setPreviousFile(currentFile) - let cancelled = false - let tickRunning = false - const id = window.setInterval(() => { - if (cancelled || tickRunning) return - tickRunning = true - try { - const captured = currentFileRef.current - if (!captured || cancelled) { - tickRunning = false - return - } - const next: FileAsset = - captured.source === 'default' - ? driftDefaultPpm(DEFAULT_SEED, overridesRef.current) - : mutateRandomBytes(captured) - if (cancelled) return - setCurrentFile(next) - } finally { - tickRunning = false - } - }, AUTO_EDIT_INTERVAL_MS) - return () => { - cancelled = true - window.clearInterval(id) - } - // We deliberately depend only on autoEdit. The interval reads currentFile via the setter callback so it stays - // current without restarting on every tick. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoEdit]) - if (!currentFile) { return

Generating default file…

} return ( // Mobile-first ordering: live sections above the file sidebar so a phone landing on the streaming demo sees the - // chunker changing immediately, then scrolls down to the file controls. See `hash-demo.tsx` for the same pattern. + // download running immediately, then scrolls down to the file controls. See `hash-demo.tsx` for the same pattern.
- setAutoEdit((v) => !v)} - /> +
- - -
@@ -147,18 +77,12 @@ export function StreamingDemo() { function FileSidebar({ current, - previous, onReplace, rejected, - autoEdit, - onToggleAutoEdit, }: { current: FileAsset - previous: FileAsset | null onReplace: (file: File) => void | Promise rejected: { name: string; size: number } | null - autoEdit: boolean - onToggleAutoEdit: () => void }) { const fileRef = useRef(null) const [dragOver, setDragOver] = useState(false) @@ -171,11 +95,6 @@ function FileSidebar({ Current file

{current.name}

{formatBytes(current.bytes.byteLength)}

- {previous ? ( -

- (prev: {previous.name}, {formatBytes(previous.bytes.byteLength)}) -

- ) : null} {rejected ? ( @@ -225,222 +144,18 @@ function FileSidebar({ }} />

- Replace the file and the delta section fills in; the prior version is captured automatically. Demo cap:{' '} - {formatBytes(MAX_FILE_BYTES)}. + Drop in your own file and stream it back verified. Demo cap: {formatBytes(MAX_FILE_BYTES)}.

- -
- -

- {current.source === 'default' - ? 'Drifts one grid cell per tick. FastCDC keeps most chunks stable while the edited region’s chunk re-hashes.' - : 'Flips 1–3 random bytes per tick, skipping any image header, so you can watch the chunker react to small edits.'} -

-
) } -// --- section 1: chunker ------------------------------------------------------ - -function StreamingChunker({ file }: { file: FileAsset }) { - const api = useMkit() - const result = useMemo(() => { - const r = api.chunk_boundaries(file.bytes) - const chunks = stripChunks(r) - return { chunks, avg: r.avg, min: r.min, max: r.max, count: r.chunk_count } - }, [api, file]) - - const [tamperByte, setTamperByte] = useState(Math.floor(file.bytes.byteLength / 2)) - // Clamp (don't re-center) when the file shrinks: auto-edit replaces the FileAsset object every tick without - // changing its length, and re-anchoring on identity snapped the slider back to the midpoint mid-drag. - useEffect(() => { - setTamperByte((b) => Math.min(b, file.bytes.byteLength - 1)) - }, [file.bytes.byteLength]) - - const deferredByte = useDeferredValue(tamperByte) - const highlightIndex = useMemo(() => { - return findChunkAtOffset(result.chunks, deferredByte) - }, [result.chunks, deferredByte]) - - return ( -
-

- {result.count} chunks · avg {formatBytes(result.avg)} ({formatBytes(result.min)}–{formatBytes(result.max)}) -

- -
- - setTamperByte(Number(e.target.value))} - className='w-full' - aria-label='Tamper byte offset' - /> -

- Edit byte {tamperByte.toLocaleString()} — only chunk {highlightIndex !== null ? highlightIndex : '–'} would - change. FastCDC's rolling hash keeps the rest stable. -

-
-
- ) -} - -// --- section 2: chunked blob ------------------------------------------------- - -function StreamingChunkedBlob({ file }: { file: FileAsset }) { - const api = useMkit() - const blob = useMemo(() => { - const r = api.chunked_blob_encode(file.bytes) - const chunks = stripChunks(r) - return { rootHash: r.root_hash_hex, bytesLen: r.bytes_len, count: r.chunk_count, chunks } - }, [api, file]) - - return ( -
-
- - {blob.chunks.map((c, i) => ( - - ))} -
-
- ) -} - -// --- section 3: delta -------------------------------------------------------- - -function StreamingDelta({ current, previous }: { current: FileAsset; previous: FileAsset | null }) { - const api = useMkit() - - const data = useMemo(() => { - if (!previous) return null - const prev = api.chunk_boundaries(previous.bytes) - const curr = api.chunk_boundaries(current.bytes) - const prevChunks = stripChunks(prev) - const currChunks = stripChunks(curr) - const prevHashes = new Set(prevChunks.map((c) => c.hash_hex)) - const currHashes = new Set(currChunks.map((c) => c.hash_hex)) - const prevDim = new Set() - prevChunks.forEach((c, i) => { - if (currHashes.has(c.hash_hex)) prevDim.add(i) - }) - const currDim = new Set() - currChunks.forEach((c, i) => { - if (prevHashes.has(c.hash_hex)) currDim.add(i) - }) - const summary = api.delta_encode(previous.bytes, current.bytes) - return { - prevChunks, - currChunks, - prevDim, - currDim, - bytesOnWire: summary.bytes_on_wire, - fullSize: summary.full_size, - } - }, [api, previous, current]) - - return ( -
- {!data ? ( -

Replace the file to see the delta.

- ) : ( - <> -
-

Previous

- -
-
-

Current

- -
- - - )} -
- ) -} - -function DeltaStat({ bytesOnWire, fullSize }: { bytesOnWire: number; fullSize: number }) { - const savings = fullSize > 0 ? (1 - bytesOnWire / fullSize) * 100 : 0 - const positive = savings > 0 - return ( -
- {positive ? ( - <> -
{Math.round(savings)}% saved
-
- {formatBytes(bytesOnWire)} sent · {formatBytes(fullSize)} for a full re-upload -
- - ) : ( - <> -
No shared chunks — full upload wins
-
- Delta is {formatBytes(bytesOnWire)} vs {formatBytes(fullSize)} full. Two files with no overlapping content - (e.g. unrelated images) ship the whole payload either way. Edit the same file to see savings. -
- - )} -
- ) -} - -// --- section 4: verified download --------------------------------------------- +// --- verified download --------------------------------------------- // Everything the stream needs, captured once when the user presses Start. Streaming a snapshot (not the live file) -// is what lets the auto-edit loop keep running underneath without resetting playback — the bug class the old -// section suffered from. `ppm` is non-null only for the default grid file (or a user-supplied raw PPM), and gates -// the progressive canvas paint; compressed uploads can't be painted from a byte prefix. +// keeps playback stable regardless of later file replacement. `ppm` is non-null only for the default grid file (or a +// user-supplied raw PPM), and gates the progressive canvas paint; compressed uploads can't be painted from a byte +// prefix. type DownloadSnapshot = { bytes: Uint8Array chunks: StripChunk[] @@ -493,6 +208,9 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Rows already blitted to the canvas, so a tick only draws the newly-completed span instead of repainting from // scratch. const paintedRowsRef = useRef(0) + // React strict-mode mounts twice; without this guard the auto-start effect would fire `start()` twice on first + // load, discarding the first snapshot. Ref survives the remount, so only the first real mount starts the stream. + const autoStartedRef = useRef(false) // Clear the canvas to the same placeholder fill `FilePreview` uses for undecodable bytes, once per fresh snapshot. // Keyed on `snapshot` (a new object every Start), never on `file` — see the tick effect below for why that split @@ -509,7 +227,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // No effect keyed on `file`. The live file drifting under a running stream is by design — the stream downloads a // snapshot taken at Start, and the copy below says so. const start = () => { - const bytes = file.bytes // FileAsset bytes are replaced, never mutated, per tick — holding the reference is safe + const bytes = file.bytes const r = api.chunk_boundaries(bytes) const chunks = stripChunks(r) const enc = api.bao_encode(bytes) @@ -520,6 +238,14 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { setDl({ ...freshDownloadState(), phase: 'streaming' }) } + // Auto-start on mount so the page demos itself on load, without waiting for a click. + useEffect(() => { + if (autoStartedRef.current) return + autoStartedRef.current = true + start() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const reset = () => { setDl(freshDownloadState()) setSnapshot(null) @@ -618,7 +344,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) {
{snapshot ? (

@@ -626,6 +352,11 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { file) · streams the file as it was at Start

) : null} + {snapshot ? ( +

+ {snapshot.chunks.length} content-defined chunks (FastCDC) · avg {formatBytes(total / snapshot.chunks.length)} +

+ ) : null} {snapshot?.ppm ? ( = c.offset && offset < c.offset + c.len) return i - } - return chunks.length > 0 ? chunks.length - 1 : null -} - // 96 px square preview of the current file. PPM is parsed and blitted directly via putImageData on an offscreen // canvas, then downscaled with `drawImage`; everything else is rendered through a Blob URL `` decode. Both paths -// fall back to a hairline placeholder if decoding fails (e.g. non-image upload). Small enough to redraw on every -// auto-edit tick without breaking the budget. +// fall back to a hairline placeholder if decoding fails (e.g. non-image upload). function FilePreview({ asset }: { asset: FileAsset }) { const canvasRef = useRef(null) diff --git a/apps/web/src/lib/ppm.ts b/apps/web/src/lib/ppm.ts index 982b10d8..0bce6e75 100644 --- a/apps/web/src/lib/ppm.ts +++ b/apps/web/src/lib/ppm.ts @@ -1,34 +1,25 @@ import { mulberry32 } from './grid-svg' -// `source` lets the auto-edit loop pick the right mutator: defaults regenerate via canvas+PPM so a single grid-cell -// edit produces a single localised byte-range change, uploads get random byte flips. +// `source` records how the bytes were produced — the default grid render, or a user upload. export type FileAsset = { name: string; bytes: Uint8Array; source: 'default' | 'upload' } const DEFAULT_NAME = 'grid.ppm' -// Build a 512×512 PPM (NetPBM P6) from a deterministic mulberry32 noise grid (256×256 cells × 2 px each), with -// optional hue overrides on specific cells. PPM rather than PNG so a single-cell edit produces a contiguous -// byte-range change (~12 bytes per cell at 2px×2px×3bytes) instead of zlib-cascading the change across the entire -// compressed stream — which is the whole point of demonstrating content-defined chunking. PPM picked over BMP for -// the smaller, format-agnostic header (`P6 width height 255` in ASCII) and natural RGB byte order. -export type CellOverride = { x: number; y: number; hue: number } +// Build a 512×512 PPM (NetPBM P6) from a deterministic mulberry32 noise grid (256×256 cells × 2 px each). PPM rather +// than PNG so the byte layout stays a flat, uncompressed grid — the whole point of demonstrating content-defined +// chunking on something format-agnostic. PPM picked over BMP for the smaller, format-agnostic header +// (`P6 width height 255` in ASCII) and natural RGB byte order. -// 512×512 raster (256 cells × 2 px) keeps ~12 FastCDC chunks (above the 4-chunk min that makes the strip readable) -// while cutting wasm bandwidth-bound work — chunker, ChunkedBlob, delta, Bao — by 2.25× vs the prior 768-pixel grid. -// Per-tick wasm budget drops below ~70 ms which restores 500 ms cadence with main-thread headroom. +// 512×512 raster (256 cells × 2 px) keeps chunk counts and wasm bandwidth-bound work (chunker, Bao) modest so the +// demo page stays responsive. const GRID_CELLS = 256 const GRID_CELL_PX = 2 const GRID_SIZE = GRID_CELLS * GRID_CELL_PX -// Module-scoped cache of the unmutated baseline RGBA grid, keyed by seed. The 65k `fillRect` baseline draw is the -// dominant cost in the per-tick auto-edit loop; once we've computed the base for a given seed, every subsequent drift -// tick just copies this buffer and writes the override pixels directly into the copy. Reset the cache by reloading -// the page (the seed never changes during a session). -const baselineCache = new Map() - -function buildBaseline(seed: number): Uint8ClampedArray { - const cached = baselineCache.get(seed) - if (cached) return cached +// Render the deterministic grid and encode it as a PPM. Called once per session — the caller (`StreamingDemo`'s +// `generatedRef`) caches the result across the strict-mode double mount, so there's no need for a module-level cache +// here. +export function generateDefaultPpm(seed: number): FileAsset { const canvas = document.createElement('canvas') canvas.width = GRID_SIZE canvas.height = GRID_SIZE @@ -43,93 +34,10 @@ function buildBaseline(seed: number): Uint8ClampedArray { } } const data = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE).data - baselineCache.set(seed, data) - return data -} - -// Resolve `hsl(h 70% 60%)` to packed RGBA (4 bytes) once per override so the per-pixel write loop is straight integer -// stores rather than a string parse + canvas state shuffle. -function hueToRgba(hue: number): [number, number, number] { - // HSL → RGB at S=70%, L=60%. Math straight from the CSS-color spec. - const s = 0.7 - const l = 0.6 - const c = (1 - Math.abs(2 * l - 1)) * s - const hp = (((hue % 360) + 360) % 360) / 60 - const x = c * (1 - Math.abs((hp % 2) - 1)) - let r1 = 0 - let g1 = 0 - let b1 = 0 - if (hp < 1) { - r1 = c - g1 = x - } else if (hp < 2) { - r1 = x - g1 = c - } else if (hp < 3) { - g1 = c - b1 = x - } else if (hp < 4) { - g1 = x - b1 = c - } else if (hp < 5) { - r1 = x - b1 = c - } else { - r1 = c - b1 = x - } - const m = l - c / 2 - return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)] -} - -export function generateDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { - const base = buildBaseline(seed) - const data = new Uint8ClampedArray(base) // copy so mutations don't pollute the cached baseline - for (const o of overrides) { - const [r, g, b] = hueToRgba(o.hue) - const x0 = o.x * GRID_CELL_PX - const y0 = o.y * GRID_CELL_PX - for (let dy = 0; dy < GRID_CELL_PX; dy++) { - for (let dx = 0; dx < GRID_CELL_PX; dx++) { - const i = ((y0 + dy) * GRID_SIZE + (x0 + dx)) * 4 - data[i] = r - data[i + 1] = g - data[i + 2] = b - // alpha stays 255 from the canvas baseline - } - } - } const bytes = encodePpm(data, GRID_SIZE, GRID_SIZE) return { name: DEFAULT_NAME, bytes, source: 'default' } } -// Push one new override onto the running list and re-render. Cell coordinate is uniformly random across the grid. -export function driftDefaultPpm(seed: number, overrides: CellOverride[]): FileAsset { - overrides.push({ - x: Math.floor(Math.random() * GRID_CELLS), - y: Math.floor(Math.random() * GRID_CELLS), - hue: Math.floor(Math.random() * 360), - }) - return generateDefaultPpm(seed, overrides) -} - -// Flip 1–3 bytes outside any recognised image header. Header preservation keeps a real PNG/JPEG/PPM upload still -// readable as that format if the user opens it elsewhere; the chunker doesn't care either way but the courtesy -// matters when a user drops in their own file. -export function mutateRandomBytes(asset: FileAsset): FileAsset { - const bytes = new Uint8Array(asset.bytes) - const headerSkip = detectHeaderSize(bytes) - const range = bytes.length - headerSkip - if (range <= 0) return asset - const flips = 1 + Math.floor(Math.random() * 3) - for (let i = 0; i < flips; i++) { - const offset = headerSkip + Math.floor(Math.random() * range) - // XOR with a non-zero mask so the byte definitely changes. - bytes[offset] = ((bytes[offset] ?? 0) ^ (1 + Math.floor(Math.random() * 255))) & 0xff - } - return { ...asset, bytes } -} - // Walk the three ASCII whitespace-separated tokens after the `P6` magic, returning width/height and the byte offset // where pixel data starts. Tolerates `#` comments per the spec. Returns null on anything else. export function decodePpmHeader(bytes: Uint8Array): { width: number; height: number; pixelStart: number } | null { @@ -171,27 +79,6 @@ export function decodePpmHeader(bytes: Uint8Array): { width: number; height: num return { width, height, pixelStart: p } } -function detectHeaderSize(bytes: Uint8Array): number { - if (bytes.length < 8) return 0 - // PNG: 89 50 4E 47 0D 0A 1A 0A - if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 8 - // PPM (P6): "P6\n \n255\n" — three ASCII lines, length varies. Walk until we've passed three - // newlines so a random byte flip lands strictly in the pixel body. - if (bytes[0] === 0x50 && bytes[1] === 0x36) { - let nl = 0 - for (let i = 2; i < Math.min(bytes.length, 64); i++) { - if (bytes[i] === 0x0a) { - nl++ - if (nl === 3) return i + 1 - } - } - return 0 - } - // JPEG: FF D8 ... start; conservative skip of first 4 bytes (SOI + first marker) - if (bytes[0] === 0xff && bytes[1] === 0xd8) return 4 - return 0 -} - // Encode RGBA pixels to a NetPBM P6 (binary PPM) blob. Header is three ASCII lines — magic `P6`, dimensions, and // max-component value 255 — followed by raw RGB triplets in scanline order. No row padding, no endian-flipped bytes, // no quirks. ~15-byte header for our 512×512 grid. From eedeea78322fcecfc9a447f54e1ee6ea29d57f61 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:58:53 -0400 Subject: [PATCH 06/12] =?UTF-8?q?feat(web):=20show=20corruption=20side-by-?= =?UTF-8?q?side=20=E2=80=94=20unverified=20vs=20Bao-verified=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corruption was invisible by design: rejected bytes never reached the verified image, so the toggle just filled a button. Add an unverified pane next to the verified one that paints whatever arrives, first attempt wins, so tampered chunks land and stay while the verified pane rejects, stalls, and re-fetches clean. The corrupt toggle now restarts an idle stream so the effect is immediate. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 172 +++++++++++++++++---- 1 file changed, 142 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index 342fe4d3..10205b50 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -16,6 +16,29 @@ function stripChunks(r: { }) } +// Blit rows [from, to) of `src` (bytes assembled at their file offsets) onto `ctx` at row `from`, converting the PPM's +// packed RGB triples into RGBA. Shared by both the verified and unverified panes so there's exactly one RGB→RGBA loop. +function paintRows( + ctx: CanvasRenderingContext2D, + src: Uint8Array, + ppm: { width: number; pixelStart: number }, + from: number, + to: number, +) { + const { width, pixelStart } = ppm + const bytesPerRow = width * 3 + const rowSpan = to - from + const rgba = new Uint8ClampedArray(width * rowSpan * 4) + let p = pixelStart + from * bytesPerRow + for (let i = 0; i < width * rowSpan; i++) { + rgba[i * 4] = src[p++]! + rgba[i * 4 + 1] = src[p++]! + rgba[i * 4 + 2] = src[p++]! + rgba[i * 4 + 3] = 255 + } + ctx.putImageData(new ImageData(rgba, width, rowSpan), 0, from) +} + const DEFAULT_SEED = 0xc0de_cafe // Demo-only hard cap. At 64 KiB avg FastCDC chunks, 128 MiB → ~2,048 chunks — the strip stays readable (each chip is // still visible at typical viewport widths) and the wasm passes finish in a few seconds on a modest laptop. Above this @@ -208,6 +231,14 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Rows already blitted to the canvas, so a tick only draws the newly-completed span instead of repainting from // scratch. const paintedRowsRef = useRef(0) + // Unverified pane: bytes as they arrive on the wire, first attempt wins — no rejection, no retry, no stall. This is + // the counterfactual the verified pane exists to prevent. + const rawAssembledRef = useRef(null) + const rawCanvasRef = useRef(null) + const rawPaintedRowsRef = useRef(0) + // Contiguous prefix the unverified receiver has accepted, in bytes. Unlike `dl.cursor` this never rewinds on a + // rejection — the raw pane took its copy the moment a fresh chunk landed and moved on. + const rawPrefixRef = useRef(0) // React strict-mode mounts twice; without this guard the auto-start effect would fire `start()` twice on first // load, discarding the first snapshot. Ref survives the remount, so only the first real mount starts the stream. const autoStartedRef = useRef(false) @@ -217,11 +248,13 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // matters. useEffect(() => { if (!snapshot?.ppm) return - const canvas = canvasRef.current - const ctx = canvas?.getContext('2d') - if (!canvas || !ctx) return - ctx.fillStyle = 'rgba(0,0,0,0.04)' - ctx.fillRect(0, 0, canvas.width, canvas.height) + for (const ref of [canvasRef, rawCanvasRef]) { + const canvas = ref.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) continue + ctx.fillStyle = 'rgba(0,0,0,0.04)' + ctx.fillRect(0, 0, canvas.width, canvas.height) + } }, [snapshot]) // No effect keyed on `file`. The live file drifting under a running stream is by design — the stream downloads a @@ -234,6 +267,9 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const ppm = decodePpmHeader(bytes) assembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null paintedRowsRef.current = 0 + rawAssembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null + rawPaintedRowsRef.current = 0 + rawPrefixRef.current = 0 setSnapshot({ bytes, chunks, rootHex: enc.hash_hex, outboard: enc.outboard, ppm }) setDl({ ...freshDownloadState(), phase: 'streaming' }) } @@ -251,6 +287,9 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { setSnapshot(null) assembledRef.current = null paintedRowsRef.current = 0 + rawAssembledRef.current = null + rawPaintedRowsRef.current = 0 + rawPrefixRef.current = 0 } // The tick: fetch `perTick` chunks as bao slices and verify each against the root as it lands. Sequential and @@ -279,6 +318,28 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const buf = new Uint8Array(slice) // Corrupt fresh fetches only; a retry after a rejection arrives clean, as if from a different mirror. if (!isRetry && corruptRef.current) buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 + + // Unverified receiver: takes the first copy of every fresh chunk unconditionally, verified or not — it has + // no hash check, so corruption (when armed) lands in the image and stays. A single flipped byte (the + // verifier's tamper above) is invisible at pixel scale, so the display corruption is synthesized separately + // as a visible stripe; the verifier still only ever sees — and rejects on — a genuinely tampered slice, and + // the copy under the pane is honest that even one bad bit would trip it. + if (!isRetry) { + const raw = rawAssembledRef.current + if (raw) { + const payload = snapshot.bytes.slice(chunk.offset, chunk.offset + chunk.len) + if (corruptRef.current) { + const spanLen = Math.max(1, Math.floor(payload.length * 0.4)) + const spanStart = Math.floor(payload.length * 0.3) + for (let i = spanStart; i < Math.min(payload.length, spanStart + spanLen); i++) { + payload[i] = (payload[i]! ^ (0xa5 + i)) & 0xff + } + } + raw.set(payload, chunk.offset) + rawPrefixRef.current = chunk.offset + chunk.len + } + } + const v = api.bao_verify_slice(snapshot.rootHex, buf, chunk.offset, chunk.len) if (v.ok) { if (assembledRef.current && v.bytes) assembledRef.current.set(v.bytes, chunk.offset) @@ -304,32 +365,49 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Verified bytes form a contiguous prefix (the stream is sequential), so paint only the newly-completed rows. if (snapshot.ppm && verifiedAny) { - const canvas = canvasRef.current - const ctx = canvas?.getContext('2d') + const ctx = canvasRef.current?.getContext('2d') const assembled = assembledRef.current if (ctx && assembled) { const { width, pixelStart } = snapshot.ppm const prefixBytes = next.cursor < snapshot.chunks.length ? snapshot.chunks[next.cursor]!.offset : snapshot.bytes.byteLength - const bytesPerRow = width * 3 - const rowsDone = Math.floor(Math.max(0, prefixBytes - pixelStart) / bytesPerRow) + const rowsDone = Math.floor(Math.max(0, prefixBytes - pixelStart) / (width * 3)) const painted = paintedRowsRef.current if (rowsDone > painted) { - const rowSpan = rowsDone - painted - const rgba = new Uint8ClampedArray(width * rowSpan * 4) - let p = pixelStart + painted * bytesPerRow - for (let i = 0; i < width * rowSpan; i++) { - rgba[i * 4] = assembled[p++]! - rgba[i * 4 + 1] = assembled[p++]! - rgba[i * 4 + 2] = assembled[p++]! - rgba[i * 4 + 3] = 255 - } - ctx.putImageData(new ImageData(rgba, width, rowSpan), 0, painted) + paintRows(ctx, assembled, snapshot.ppm, painted, rowsDone) paintedRowsRef.current = rowsDone } } } + // Unverified pane: paint whatever the raw receiver has accepted so far, rejection or not — its prefix only + // ever advances (see the fresh-fetch block above), so it never needs the retry-aware prefix math the verified + // pane uses. + if (snapshot.ppm) { + const ctx = rawCanvasRef.current?.getContext('2d') + const raw = rawAssembledRef.current + if (ctx && raw) { + const { width, pixelStart } = snapshot.ppm + const rowsDone = Math.floor(Math.max(0, rawPrefixRef.current - pixelStart) / (width * 3)) + const painted = rawPaintedRowsRef.current + if (rowsDone > painted) { + paintRows(ctx, raw, snapshot.ppm, painted, rowsDone) + rawPaintedRowsRef.current = rowsDone + } + } + } + + // Stall band on the verified canvas: marks the row where the rejected chunk would have painted. The next + // tick's clean retry paints starting at `paintedRowsRef.current` (unchanged since this tick, because the + // rejection broke the loop before advancing it) via the block above, overwriting this band. + if (snapshot.ppm && next.retryPending) { + const ctx = canvasRef.current?.getContext('2d') + if (ctx) { + ctx.fillStyle = 'rgba(220,38,38,0.85)' + ctx.fillRect(0, paintedRowsRef.current, snapshot.ppm.width, 2) + } + } + setDl(next) }, delayMs) return () => clearTimeout(t) @@ -340,6 +418,15 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const startLabel = dl.phase === 'streaming' ? 'Downloading…' : dl.phase === 'done' ? 'Download again' : 'Start download' + // Arming the toggle while idle would otherwise be invisible until the next Start — restart so the effect is + // immediate. corruptRef is written by the render this setCorrupt triggers, and the first tick fires no sooner than + // `delayMs` (>=100ms) later, so start()'s fresh snapshot is in place well before corruptRef is read. + const toggleCorrupt = () => { + const next = !corrupt + setCorrupt(next) + if (next && dl.phase !== 'streaming') start() + } + return (
) : null} {snapshot?.ppm ? ( - +
+
+ +

Without verification — corrupted chunks land in the image and stay.

+
+
+ +

+ Verified — every chunk checked against the Bao root; corruption never gets in. +

+
+
) : null} {snapshot ? ( ) : null} + {dl.retryPending ? ( +

+ Chunk {dl.cursor} arrived corrupted — rejected by the verifier (hash mismatch). Re-fetching… +

+ ) : null}
{corrupt ? (

- Every fresh chunk arrives tampered; the verifier rejects it and the re-fetch comes in clean. + Every fresh chunk arrives tampered. Watch the left image take damage while the verifier keeps the right one + clean.

) : null} {snapshot ? ( From 563aee71444fa4280daa45031e2a4e0e353b6121 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:01:45 -0400 Subject: [PATCH 07/12] fix(web): make the corruption comparison legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panes now sit side by side (w-40 pins caption wrap), canvases actually clear on restart (clearRect before the translucent placeholder fill), and corrupted spans render as solid black stripes — XOR noise was invisible against the noise-grid default file. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index 10205b50..fbd1d95d 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -252,6 +252,9 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const canvas = ref.current const ctx = canvas?.getContext('2d') if (!canvas || !ctx) continue + // clearRect first: the placeholder fill is translucent, and on a restart it would otherwise just tint the + // previous run's image instead of erasing it. + ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = 'rgba(0,0,0,0.04)' ctx.fillRect(0, 0, canvas.width, canvas.height) } @@ -329,11 +332,11 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { if (raw) { const payload = snapshot.bytes.slice(chunk.offset, chunk.offset + chunk.len) if (corruptRef.current) { + // Zero a ~40% span to black: a dead stripe reads as damage against any content — XOR noise would be + // indistinguishable from the noise-grid default file. const spanLen = Math.max(1, Math.floor(payload.length * 0.4)) const spanStart = Math.floor(payload.length * 0.3) - for (let i = spanStart; i < Math.min(payload.length, spanStart + spanLen); i++) { - payload[i] = (payload[i]! ^ (0xa5 + i)) & 0xff - } + payload.fill(0, spanStart, Math.min(payload.length, spanStart + spanLen)) } raw.set(payload, chunk.offset) rawPrefixRef.current = chunk.offset + chunk.len @@ -446,7 +449,8 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { ) : null} {snapshot?.ppm ? (
-
+ {/* w-40 pins each pane to its canvas width so the captions wrap instead of forcing the panes to stack. */} +

Without verification — corrupted chunks land in the image and stay.

-
+
Date: Fri, 17 Jul 2026 13:25:11 -0400 Subject: [PATCH 08/12] =?UTF-8?q?refactor(web):=20streaming=20demo=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20single=20image,=20corrupt=20switch,=20CTA=20i?= =?UTF-8?q?dle=20state,=20drop=20uploads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Erica's live-page annotation feedback on the verified-download demo: collapses the raw/verified comparison to one verified image, turns the corrupt control into an accessible switch placed above the buttons, and makes the idle state a lone Start CTA. Removes duplicated/explanatory copy and upload support (drag-and-drop, file input, size cap, and the now-dead FileAsset.source field and FilePreview blob/img fallback) since the demo only ever streams its generated PPM. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 387 ++++++--------------- apps/web/src/lib/ppm.ts | 5 +- 2 files changed, 108 insertions(+), 284 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index fbd1d95d..0a785ef1 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -17,7 +17,7 @@ function stripChunks(r: { } // Blit rows [from, to) of `src` (bytes assembled at their file offsets) onto `ctx` at row `from`, converting the PPM's -// packed RGB triples into RGBA. Shared by both the verified and unverified panes so there's exactly one RGB→RGBA loop. +// packed RGB triples into RGBA. function paintRows( ctx: CanvasRenderingContext2D, src: Uint8Array, @@ -40,16 +40,9 @@ function paintRows( } const DEFAULT_SEED = 0xc0de_cafe -// Demo-only hard cap. At 64 KiB avg FastCDC chunks, 128 MiB → ~2,048 chunks — the strip stays readable (each chip is -// still visible at typical viewport widths) and the wasm passes finish in a few seconds on a modest laptop. Above this -// we'd risk OOM in wasm32 (the linear memory ceiling is 4 GiB and our passes hold multiple copies — input, chunker -// state, Bao outboard) and start freezing the tab while building tens of thousands of DOM nodes. Real mkit has no -// such limit; the cap only applies to this interactive page. -const MAX_FILE_BYTES = 128 * 1024 * 1024 export function StreamingDemo() { const [currentFile, setCurrentFile] = useState(null) - const [tooLarge, setTooLarge] = useState<{ name: string; size: number } | null>(null) // React strict-mode mounts twice; cache the generated default bytes so we don't burn the canvas pipeline twice on // first paint. Ref survives the remount. const generatedRef = useRef(null) @@ -64,20 +57,6 @@ export function StreamingDemo() { setCurrentFile(asset) }, []) - // Reject oversized files *before* reading the ArrayBuffer — saves the browser from allocating hundreds of MiB just - // to throw it away. Real mkit has no cap; this is purely to keep the demo page responsive. - const tryReplaceFile = async (file: File) => { - const name = file.name || 'file' - if (file.size > MAX_FILE_BYTES) { - setTooLarge({ name, size: file.size }) - return - } - const buf = await file.arrayBuffer() - setTooLarge(null) - const next: FileAsset = { name, bytes: new Uint8Array(buf), source: 'upload' } - setCurrentFile(next) - } - if (!currentFile) { return

Generating default file…

} @@ -87,7 +66,7 @@ export function StreamingDemo() { // download running immediately, then scrolls down to the file controls. See `hash-demo.tsx` for the same pattern.
- +
@@ -98,77 +77,15 @@ export function StreamingDemo() { // --- sidebar ----------------------------------------------------------------- -function FileSidebar({ - current, - onReplace, - rejected, -}: { - current: FileAsset - onReplace: (file: File) => void | Promise - rejected: { name: string; size: number } | null -}) { - const fileRef = useRef(null) - const [dragOver, setDragOver] = useState(false) - +function FileSidebar({ current }: { current: FileAsset }) { return ( -
-
- -
- Current file -

{current.name}

-

{formatBytes(current.bytes.byteLength)}

-
-
- {rejected ? ( -

- {rejected.name} is {formatBytes(rejected.size)}. The demo cap is{' '} - {formatBytes(MAX_FILE_BYTES)} to keep this page responsive. mkit handles gigabytes; the cap only applies to - this interactive page. -

- ) : null} - -
{ - e.preventDefault() - setDragOver(true) - }} - onDragLeave={() => setDragOver(false)} - onDrop={(e) => { - e.preventDefault() - setDragOver(false) - const f = e.dataTransfer.files?.[0] - if (f) void onReplace(f) - }} - className={`rounded-md border border-dashed p-3 text-xs transition-colors ${ - // text-muted must live in the else branch: both .text-* classes set the same property, so - // stylesheet order (not className order) would decide and text-fg loses to a later text-muted. - dragOver ? 'border-fg text-fg' : 'border-hairline text-muted' - }`} - > - Drop a file here, or - - . +
+ +
+ Current file +

{current.name}

+

{formatBytes(current.bytes.byteLength)}

- { - const f = e.target.files?.[0] - if (f) void onReplace(f) - e.target.value = '' - }} - /> -

- Drop in your own file and stream it back verified. Demo cap: {formatBytes(MAX_FILE_BYTES)}. -

) } @@ -176,9 +93,9 @@ function FileSidebar({ // --- verified download --------------------------------------------- // Everything the stream needs, captured once when the user presses Start. Streaming a snapshot (not the live file) -// keeps playback stable regardless of later file replacement. `ppm` is non-null only for the default grid file (or a -// user-supplied raw PPM), and gates the progressive canvas paint; compressed uploads can't be painted from a byte -// prefix. +// keeps playback stable across a fresh Start. `ppm` is non-null in practice — the only file here is the generated +// grid PPM — and gates the progressive canvas paint, but stays nullable: decodePpmHeader still returns null on +// malformed bytes, and the guard is cheap. type DownloadSnapshot = { bytes: Uint8Array chunks: StripChunk[] @@ -214,8 +131,8 @@ function freshDownloadState(): DownloadState { } // Nominal stream length regardless of chunk count: delay between ticks is clamp(6000 / chunkCount, 100, 600) ms and -// chunks fetched per tick is max(1, ceil(chunkCount / 60)). The floor keeps huge uploads from taking minutes; the -// ceiling keeps the 9-chunk default slow enough to actually watch chunks light up one at a time. +// chunks fetched per tick is max(1, ceil(chunkCount / 60)). The floor keeps a much larger file from taking minutes; +// the ceiling keeps the 9-chunk default slow enough to actually watch chunks light up one at a time. const STREAM_TARGET_MS = 6000 function StreamingVerifiedDownload({ file }: { file: FileAsset }) { @@ -231,14 +148,6 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Rows already blitted to the canvas, so a tick only draws the newly-completed span instead of repainting from // scratch. const paintedRowsRef = useRef(0) - // Unverified pane: bytes as they arrive on the wire, first attempt wins — no rejection, no retry, no stall. This is - // the counterfactual the verified pane exists to prevent. - const rawAssembledRef = useRef(null) - const rawCanvasRef = useRef(null) - const rawPaintedRowsRef = useRef(0) - // Contiguous prefix the unverified receiver has accepted, in bytes. Unlike `dl.cursor` this never rewinds on a - // rejection — the raw pane took its copy the moment a fresh chunk landed and moved on. - const rawPrefixRef = useRef(0) // React strict-mode mounts twice; without this guard the auto-start effect would fire `start()` twice on first // load, discarding the first snapshot. Ref survives the remount, so only the first real mount starts the stream. const autoStartedRef = useRef(false) @@ -248,16 +157,14 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // matters. useEffect(() => { if (!snapshot?.ppm) return - for (const ref of [canvasRef, rawCanvasRef]) { - const canvas = ref.current - const ctx = canvas?.getContext('2d') - if (!canvas || !ctx) continue - // clearRect first: the placeholder fill is translucent, and on a restart it would otherwise just tint the - // previous run's image instead of erasing it. - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.fillStyle = 'rgba(0,0,0,0.04)' - ctx.fillRect(0, 0, canvas.width, canvas.height) - } + const canvas = canvasRef.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + // clearRect first: the placeholder fill is translucent, and on a restart it would otherwise just tint the + // previous run's image instead of erasing it. + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'rgba(0,0,0,0.04)' + ctx.fillRect(0, 0, canvas.width, canvas.height) }, [snapshot]) // No effect keyed on `file`. The live file drifting under a running stream is by design — the stream downloads a @@ -270,9 +177,6 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const ppm = decodePpmHeader(bytes) assembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null paintedRowsRef.current = 0 - rawAssembledRef.current = ppm ? new Uint8Array(bytes.byteLength) : null - rawPaintedRowsRef.current = 0 - rawPrefixRef.current = 0 setSnapshot({ bytes, chunks, rootHex: enc.hash_hex, outboard: enc.outboard, ppm }) setDl({ ...freshDownloadState(), phase: 'streaming' }) } @@ -290,16 +194,12 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { setSnapshot(null) assembledRef.current = null paintedRowsRef.current = 0 - rawAssembledRef.current = null - rawPaintedRowsRef.current = 0 - rawPrefixRef.current = 0 } // The tick: fetch `perTick` chunks as bao slices and verify each against the root as it lands. Sequential and // single-flight by construction — the timeout only re-arms once `setDl` has landed a new `dl`, so there's no - // stacking even if a tick runs long (the 128 MiB cap can take longer than `delayMs`; see the guide's measured - // numbers). Corruption (when armed) hits only the first attempt at a chunk; the retry that follows a rejection - // always arrives clean, telling the "different mirror" story the toggle's helper copy promises. + // stacking even if a tick runs long. Corruption (when armed) hits only the first attempt at a chunk; the retry + // that follows a rejection always arrives clean, as if from a different mirror. useEffect(() => { if (dl.phase !== 'streaming' || !snapshot) return const delayMs = Math.max(100, Math.min(600, Math.round(STREAM_TARGET_MS / snapshot.chunks.length))) @@ -322,27 +222,6 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Corrupt fresh fetches only; a retry after a rejection arrives clean, as if from a different mirror. if (!isRetry && corruptRef.current) buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 - // Unverified receiver: takes the first copy of every fresh chunk unconditionally, verified or not — it has - // no hash check, so corruption (when armed) lands in the image and stays. A single flipped byte (the - // verifier's tamper above) is invisible at pixel scale, so the display corruption is synthesized separately - // as a visible stripe; the verifier still only ever sees — and rejects on — a genuinely tampered slice, and - // the copy under the pane is honest that even one bad bit would trip it. - if (!isRetry) { - const raw = rawAssembledRef.current - if (raw) { - const payload = snapshot.bytes.slice(chunk.offset, chunk.offset + chunk.len) - if (corruptRef.current) { - // Zero a ~40% span to black: a dead stripe reads as damage against any content — XOR noise would be - // indistinguishable from the noise-grid default file. - const spanLen = Math.max(1, Math.floor(payload.length * 0.4)) - const spanStart = Math.floor(payload.length * 0.3) - payload.fill(0, spanStart, Math.min(payload.length, spanStart + spanLen)) - } - raw.set(payload, chunk.offset) - rawPrefixRef.current = chunk.offset + chunk.len - } - } - const v = api.bao_verify_slice(snapshot.rootHex, buf, chunk.offset, chunk.len) if (v.ok) { if (assembledRef.current && v.bytes) assembledRef.current.set(v.bytes, chunk.offset) @@ -383,23 +262,6 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { } } - // Unverified pane: paint whatever the raw receiver has accepted so far, rejection or not — its prefix only - // ever advances (see the fresh-fetch block above), so it never needs the retry-aware prefix math the verified - // pane uses. - if (snapshot.ppm) { - const ctx = rawCanvasRef.current?.getContext('2d') - const raw = rawAssembledRef.current - if (ctx && raw) { - const { width, pixelStart } = snapshot.ppm - const rowsDone = Math.floor(Math.max(0, rawPrefixRef.current - pixelStart) / (width * 3)) - const painted = rawPaintedRowsRef.current - if (rowsDone > painted) { - paintRows(ctx, raw, snapshot.ppm, painted, rowsDone) - rawPaintedRowsRef.current = rowsDone - } - } - } - // Stall band on the verified canvas: marks the row where the rejected chunk would have painted. The next // tick's clean retry paints starting at `paintedRowsRef.current` (unchanged since this tick, because the // rejection broke the loop before advancing it) via the block above, overwriting this band. @@ -431,11 +293,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { } return ( -
+
{snapshot ? (

Bao root: {snapshot.rootHex.slice(0, 16)}… · outboard {formatBytes(snapshot.outboard.length)} (~6% of the @@ -448,35 +306,15 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) {

) : null} {snapshot?.ppm ? ( -
- {/* w-40 pins each pane to its canvas width so the captions wrap instead of forcing the panes to stack. */} -
- -

Without verification — corrupted chunks land in the image and stay.

-
-
- -

- Verified — every chunk checked against the Bao root; corruption never gets in. -

-
-
+ ) : null} {snapshot ? ( ) : null} -
+ {snapshot ? ( + <> + +
+ + +
+ + ) : ( - - -
- {corrupt ? ( -

- Every fresh chunk arrives tampered. Watch the left image take damage while the verifier keeps the right one - clean. -

- ) : null} + )} {snapshot ? (

Verified {formatBytes(dl.verifiedBytes)} of {formatBytes(total)} · {formatBytes(dl.wireBytes)} on the wire ·{' '} {formatBytes(proofBytes)} proof · {dl.rejected} rejected ({formatBytes(dl.wastedBytes)} wasted)

- ) : ( -

- Press Start download to stream the current file back, verifying every chunk against its Bao root. -

- )} + ) : null} {dl.phase === 'done' ? (

Complete — every byte verified before it was shown.

) : null} @@ -553,23 +384,47 @@ function Section({ }: { id: string title: string - description: string + description?: string children: React.ReactNode }) { return (

{title}

-

{description}

+ {description ?

{description}

: null}
{children}
) } -// 96 px square preview of the current file. PPM is parsed and blitted directly via putImageData on an offscreen -// canvas, then downscaled with `drawImage`; everything else is rendered through a Blob URL `` decode. Both paths -// fall back to a hairline placeholder if decoding fails (e.g. non-image upload). +// Minimal accessible switch — no switch component exists elsewhere in the codebase (theme-toggle is a plain button). +// `role='switch'` + `aria-checked` gives it the right semantics; the pill track and sliding thumb are plain divs. +function CorruptSwitch({ checked, onToggle }: { checked: boolean; onToggle: () => void }) { + return ( +
+ + Corrupt the connection +
+ ) +} + +// 96 px square preview of the current file. The file here is always our generated PPM: parsed and blitted directly +// via putImageData on an offscreen canvas, then downscaled with `drawImage`. Falls back to a hairline placeholder if +// decoding fails. function FilePreview({ asset }: { asset: FileAsset }) { const canvasRef = useRef(null) @@ -578,9 +433,9 @@ function FilePreview({ asset }: { asset: FileAsset }) { if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return - let cancelled = false - const drawPlaceholder = () => { + const ppm = decodePpmHeader(asset.bytes) + if (!ppm) { ctx.fillStyle = 'rgba(0,0,0,0.04)' ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillStyle = 'rgba(0,0,0,0.3)' @@ -588,54 +443,24 @@ function FilePreview({ asset }: { asset: FileAsset }) { ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText('binary', canvas.width / 2, canvas.height / 2) - } - - const ppm = decodePpmHeader(asset.bytes) - if (ppm) { - const { width, height, pixelStart } = ppm - const rgba = new Uint8ClampedArray(width * height * 4) - const src = asset.bytes - for (let i = 0, p = pixelStart; i < width * height; i++) { - rgba[i * 4] = src[p++]! - rgba[i * 4 + 1] = src[p++]! - rgba[i * 4 + 2] = src[p++]! - rgba[i * 4 + 3] = 255 - } - const off = document.createElement('canvas') - off.width = width - off.height = height - off.getContext('2d')?.putImageData(new ImageData(rgba, width, height), 0, 0) - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = 'low' - ctx.drawImage(off, 0, 0, canvas.width, canvas.height) return } - - // Decode anything can handle — PNG, JPEG, GIF, WebP, BMP. Copy into a fresh ArrayBuffer-backed view so - // the Blob constructor's BlobPart type (which rejects Uint8Array) is satisfied. - const owned = new Uint8Array(asset.bytes.byteLength) - owned.set(asset.bytes) - const blob = new Blob([owned.buffer]) - const url = URL.createObjectURL(blob) - const img = new Image() - img.onload = () => { - if (cancelled) return - ctx.clearRect(0, 0, canvas.width, canvas.height) - ctx.imageSmoothingEnabled = true - ctx.imageSmoothingQuality = 'low' - ctx.drawImage(img, 0, 0, canvas.width, canvas.height) - URL.revokeObjectURL(url) - } - img.onerror = () => { - if (!cancelled) drawPlaceholder() - URL.revokeObjectURL(url) - } - img.src = url - - return () => { - cancelled = true - URL.revokeObjectURL(url) + const { width, height, pixelStart } = ppm + const rgba = new Uint8ClampedArray(width * height * 4) + const src = asset.bytes + for (let i = 0, p = pixelStart; i < width * height; i++) { + rgba[i * 4] = src[p++]! + rgba[i * 4 + 1] = src[p++]! + rgba[i * 4 + 2] = src[p++]! + rgba[i * 4 + 3] = 255 } + const off = document.createElement('canvas') + off.width = width + off.height = height + off.getContext('2d')?.putImageData(new ImageData(rgba, width, height), 0, 0) + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'low' + ctx.drawImage(off, 0, 0, canvas.width, canvas.height) }, [asset]) return ( diff --git a/apps/web/src/lib/ppm.ts b/apps/web/src/lib/ppm.ts index 0bce6e75..d13f3bfd 100644 --- a/apps/web/src/lib/ppm.ts +++ b/apps/web/src/lib/ppm.ts @@ -1,7 +1,6 @@ import { mulberry32 } from './grid-svg' -// `source` records how the bytes were produced — the default grid render, or a user upload. -export type FileAsset = { name: string; bytes: Uint8Array; source: 'default' | 'upload' } +export type FileAsset = { name: string; bytes: Uint8Array } const DEFAULT_NAME = 'grid.ppm' @@ -35,7 +34,7 @@ export function generateDefaultPpm(seed: number): FileAsset { } const data = ctx.getImageData(0, 0, GRID_SIZE, GRID_SIZE).data const bytes = encodePpm(data, GRID_SIZE, GRID_SIZE) - return { name: DEFAULT_NAME, bytes, source: 'default' } + return { name: DEFAULT_NAME, bytes } } // Walk the three ASCII whitespace-separated tokens after the `P6` magic, returning width/height and the byte offset From 0e6eb40a4c3ea4c54f2d9b7ae52b40c90c2f77fa Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:29:58 -0400 Subject: [PATCH 09/12] feat(web): rename demos page to concepts (nav, slug, redirects) Co-Authored-By: Claude Fable 5 --- apps/web/src/cache-headers.test.ts | 2 +- apps/web/src/components/demos-tabs.tsx | 2 +- apps/web/src/components/header.tsx | 4 +-- .../web/src/pages/{demos.tsx => concepts.tsx} | 6 ++-- apps/web/src/pages/index.tsx | 12 ++++---- apps/web/src/redirects.test.ts | 29 ++++++++++++------- apps/web/src/redirects.ts | 22 +++++++++----- 7 files changed, 46 insertions(+), 31 deletions(-) rename apps/web/src/pages/{demos.tsx => concepts.tsx} (65%) diff --git a/apps/web/src/cache-headers.test.ts b/apps/web/src/cache-headers.test.ts index aedb0d3e..9e74ef79 100644 --- a/apps/web/src/cache-headers.test.ts +++ b/apps/web/src/cache-headers.test.ts @@ -48,7 +48,7 @@ describe('cacheHeadersMiddleware', () => { const mw = cacheHeadersMiddleware() const c = ctx('GET', new Response('placeholder')) const next = async () => { - c.res = new Response(null, { status: 301, headers: { Location: '/demos#hash' } }) + c.res = new Response(null, { status: 301, headers: { Location: '/concepts#hash' } }) } await mw(c, next) diff --git a/apps/web/src/components/demos-tabs.tsx b/apps/web/src/components/demos-tabs.tsx index b29fa04f..9857ecc2 100644 --- a/apps/web/src/components/demos-tabs.tsx +++ b/apps/web/src/components/demos-tabs.tsx @@ -177,7 +177,7 @@ export function DemosTabs() { the active tab (wrapping around). Switches tabs in place rather than navigating away. */}
-

More demos to explore

+

More concepts to explore

    {upNext.map((t) => (
  • diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 816d5659..105b3e66 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -2,12 +2,12 @@ import { Link } from 'waku' import { GridLogo } from './grid-logo' import { ThemeToggle } from './theme-toggle' -// Top-nav order: the combined demos playground first (it now folds in the +// Top-nav order: the combined concepts playground first (it now folds in the // tree and push walkthroughs as tabs), then the doc-style pages // (performance, parity, specs), then the live multiplayer room. Reordering // the site nav is editing this list — nothing else. const NAV_LINKS = [ - { to: '/demos', label: 'demos' }, + { to: '/concepts', label: 'concepts' }, { to: '/performance', label: 'performance' }, { to: '/parity', label: 'parity' }, { to: '/specs', label: 'specs' }, diff --git a/apps/web/src/pages/demos.tsx b/apps/web/src/pages/concepts.tsx similarity index 65% rename from apps/web/src/pages/demos.tsx rename to apps/web/src/pages/concepts.tsx index 41dc2d61..cdef7b7b 100644 --- a/apps/web/src/pages/demos.tsx +++ b/apps/web/src/pages/concepts.tsx @@ -6,9 +6,9 @@ export default function DemosPage() { return (
    diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 6840b5eb..a5f0171e 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -91,8 +91,8 @@ export default function HomePage() {
      = { - '/demos': + '/concepts': 'radial-gradient(at 18% 22%, rgba(99,102,241,0.10), transparent 55%), radial-gradient(at 82% 12%, rgba(56,189,248,0.08), transparent 55%)', '/performance': 'radial-gradient(at 18% 18%, rgba(251,146,60,0.09), transparent 55%), radial-gradient(at 82% 80%, rgba(248,113,113,0.08), transparent 55%)', @@ -148,7 +148,7 @@ const MESH: Record = { // Per-tile accent colour (solid hue echoing each tile's mesh) for the header shape. const SHAPE_COLOR: Record = { - '/demos': 'rgb(99,102,241)', + '/concepts': 'rgb(99,102,241)', '/performance': 'rgb(249,115,22)', '/parity': 'rgb(139,92,246)', '/multiplayer': 'rgb(236,72,153)', @@ -166,7 +166,7 @@ function TileShape({ to }: { to: DemoRoute }) { } as const const shape = (() => { switch (to) { - case '/demos': + case '/concepts': return ( <> diff --git a/apps/web/src/redirects.test.ts b/apps/web/src/redirects.test.ts index c0c81432..4f4ab636 100644 --- a/apps/web/src/redirects.test.ts +++ b/apps/web/src/redirects.test.ts @@ -3,23 +3,24 @@ import { redirectMiddleware, resolveRedirect } from './redirects' describe('resolveRedirect', () => { it.each([ - ['/hash', '/demos#hash'], - ['/tree', '/demos#tree'], - ['/sign', '/demos#sign'], - ['/streaming', '/demos#streaming'], - ['/push', '/demos#push'], - ['/attest', '/demos#attest'], + ['/hash', '/concepts#hash'], + ['/tree', '/concepts#tree'], + ['/sign', '/concepts#sign'], + ['/streaming', '/concepts#streaming'], + ['/push', '/concepts#push'], + ['/attest', '/concepts#attest'], + ['/demos', '/concepts'], ])('maps deleted route %s to %s', (from, to) => { expect(resolveRedirect(from)).toBe(to) }) it('tolerates a single trailing slash', () => { - expect(resolveRedirect('/sign/')).toBe('/demos#sign') + expect(resolveRedirect('/sign/')).toBe('/concepts#sign') }) it('leaves live and unknown routes alone', () => { expect(resolveRedirect('/')).toBeNull() - expect(resolveRedirect('/demos')).toBeNull() + expect(resolveRedirect('/concepts')).toBeNull() expect(resolveRedirect('/multiplayer')).toBeNull() expect(resolveRedirect('/hash/extra')).toBeNull() }) @@ -41,11 +42,19 @@ describe('redirectMiddleware', () => { expect(nextCalled).toBe(false) expect(res).toBeInstanceOf(Response) expect((res as Response).status).toBe(301) - expect((res as Response).headers.get('Location')).toBe('/demos#streaming') + expect((res as Response).headers.get('Location')).toBe('/concepts#streaming') }) - it('delegates to next for a live route', async () => { + it('301s the renamed /demos page to /concepts without calling next', async () => { const { res, nextCalled } = await run('/demos') + expect(nextCalled).toBe(false) + expect(res).toBeInstanceOf(Response) + expect((res as Response).status).toBe(301) + expect((res as Response).headers.get('Location')).toBe('/concepts') + }) + + it('delegates to next for a live route', async () => { + const { res, nextCalled } = await run('/concepts') expect(nextCalled).toBe(true) expect(res).toBeUndefined() }) diff --git a/apps/web/src/redirects.ts b/apps/web/src/redirects.ts index 001d752b..81b9510f 100644 --- a/apps/web/src/redirects.ts +++ b/apps/web/src/redirects.ts @@ -1,21 +1,26 @@ // Legacy-route redirects. // // The hash/sign/tree/streaming/push/attest demos used to each have their own -// page; they are now tabs on `/demos` (see components/demos-tabs.tsx, which +// page; they are now tabs on `/concepts` (see components/demos-tabs.tsx, which // keys off `#hash | #tree | #sign | #streaming | #push | #attest`). The old // pages were deleted, so those paths no longer prerender to a static asset — // which means Cloudflare hands the request to the Worker's Hono app, where this // middleware can 301 it to the new anchor instead of letting the RSC router // 404. Permanent (301) because the old URLs are gone for good: search engines // and any existing inbound links should learn the new location. +// +// `/demos` itself was renamed to `/concepts`; it 301s the same way. Fragments +// aren't visible server-side, but browsers carry them over automatically on a +// same-path-shape redirect, so a plain path 301 is enough. const REDIRECTS: Record = { - '/hash': '/demos#hash', - '/tree': '/demos#tree', - '/sign': '/demos#sign', - '/streaming': '/demos#streaming', - '/push': '/demos#push', - '/attest': '/demos#attest', + '/hash': '/concepts#hash', + '/tree': '/concepts#tree', + '/sign': '/concepts#sign', + '/streaming': '/concepts#streaming', + '/push': '/concepts#push', + '/attest': '/concepts#attest', + '/demos': '/concepts', } /** @@ -33,7 +38,8 @@ export function resolveRedirect(pathname: string): string | null { type RedirectContext = { req: { raw: Request } } /** - * Cloudflare-adapter middleware factory that 301s the deleted demo routes to their `/demos#…` anchors. + * Cloudflare-adapter middleware factory that 301s the deleted demo routes to their `/concepts#…` anchors (and the + * renamed `/demos` page to `/concepts`). * * Like the installer sniff, this must be wired via the adapter's `middlewareFns` option (see waku.server.tsx): it runs * inside the deployed Worker's Hono app, before the RSC page router, so it intercepts a path that no longer has a From 712db9d684fccda8440691e2ef24f0a0c32ace97 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:31:57 -0400 Subject: [PATCH 10/12] fix(web): give the corrupt switch an accessible name and clickable label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pill was a bare role='switch' button whose only child was aria-hidden — no accessible name, and clicking the visible text did nothing. Track, thumb, and label now live inside the button. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index 0a785ef1..24fe0e04 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -402,12 +402,16 @@ function Section({ // `role='switch'` + `aria-checked` gives it the right semantics; the pill track and sliding thumb are plain divs. function CorruptSwitch({ checked, onToggle }: { checked: boolean; onToggle: () => void }) { return ( -
      - - Corrupt the connection -
      + + Corrupt the connection + ) } From 1fb601278939ad96c68448219df9f3f2d0f4e9fa Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:33:01 -0400 Subject: [PATCH 11/12] fix(web): drop stale upload mention from the streaming tab intro Co-Authored-By: Claude Fable 5 --- apps/web/src/components/demos-tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/demos-tabs.tsx b/apps/web/src/components/demos-tabs.tsx index 9857ecc2..6a2482ac 100644 --- a/apps/web/src/components/demos-tabs.tsx +++ b/apps/web/src/components/demos-tabs.tsx @@ -74,7 +74,7 @@ const TABS: Tab[] = [ <> mkit cuts big files into content-defined chunks (FastCDC) and verifies each one against a Bao root as it arrives — corruption is caught mid-stream and re-fetched, not discovered after the download. Watch the file stream in - verified, corrupt the connection, or drop in your own large file. + verified, or corrupt the connection and see the verifier catch it. ), Demo: StreamingDemo, From 2026817b90f22b447c7421128d2d5b06c7aeb520 Mon Sep 17 00:00:00 2001 From: Erica Gregor <137734572+heavygweit@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:36:41 -0400 Subject: [PATCH 12/12] fix(web): corrupted connection now stalls the stream until fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old "retry arrives clean" rule made corruption consequence-free: every rejected chunk auto-healed on its second attempt, so the image always completed and the chunk strip just flickered red. Now every attempt is tampered while the toggle is on — the stream pins at the current chunk, waste climbs, and the image visibly stops filling until the connection is fixed. Deletes the retryPending/isRetry mode and the per-tick batching that only existed for the removed 128 MiB uploads. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/streaming-demo.tsx | 140 +++++++++------------ 1 file changed, 61 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/streaming-demo.tsx b/apps/web/src/components/streaming-demo.tsx index 24fe0e04..49b888b0 100644 --- a/apps/web/src/components/streaming-demo.tsx +++ b/apps/web/src/components/streaming-demo.tsx @@ -107,12 +107,12 @@ type DownloadSnapshot = { type DownloadState = { phase: 'idle' | 'streaming' | 'done' cursor: number // next chunk index to fetch - retryPending: boolean // chunk at `cursor` failed verification last tick; next fetch is the clean retry + failing: boolean // the last attempt at `cursor` was rejected; stays true as long as the connection stays corrupted verified: Set verifiedBytes: number // payload bytes that passed verification wireBytes: number // everything sent: payloads + proofs, including rejected slices wastedBytes: number // slices that failed verification and were thrown away - rejected: number // count of rejected slices + rejected: number // count of rejected attempts } // Factory, not a shared constant: DownloadState holds a Set, and a module-level instance would alias the same Set @@ -121,7 +121,7 @@ function freshDownloadState(): DownloadState { return { phase: 'idle', cursor: 0, - retryPending: false, + failing: false, verified: new Set(), verifiedBytes: 0, wireBytes: 0, @@ -130,10 +130,9 @@ function freshDownloadState(): DownloadState { } } -// Nominal stream length regardless of chunk count: delay between ticks is clamp(6000 / chunkCount, 100, 600) ms and -// chunks fetched per tick is max(1, ceil(chunkCount / 60)). The floor keeps a much larger file from taking minutes; -// the ceiling keeps the 9-chunk default slow enough to actually watch chunks light up one at a time. -const STREAM_TARGET_MS = 6000 +// One chunk per tick. The only file here is the generated grid (~9 chunks), so 600 ms × 9 ≈ 5.4 s — slow enough to +// watch chunks verify one at a time. +const TICK_MS = 600 function StreamingVerifiedDownload({ file }: { file: FileAsset }) { const api = useMkit() @@ -167,8 +166,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { ctx.fillRect(0, 0, canvas.width, canvas.height) }, [snapshot]) - // No effect keyed on `file`. The live file drifting under a running stream is by design — the stream downloads a - // snapshot taken at Start, and the copy below says so. + // Capture everything the stream needs up front — the tick works only off the snapshot. const start = () => { const bytes = file.bytes const r = api.chunk_boundaries(bytes) @@ -196,85 +194,68 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { paintedRowsRef.current = 0 } - // The tick: fetch `perTick` chunks as bao slices and verify each against the root as it lands. Sequential and - // single-flight by construction — the timeout only re-arms once `setDl` has landed a new `dl`, so there's no - // stacking even if a tick runs long. Corruption (when armed) hits only the first attempt at a chunk; the retry - // that follows a rejection always arrives clean, as if from a different mirror. + // The tick: fetch the next chunk as a bao slice and verify it against the root. Sequential and single-flight by + // construction — the timeout only re-arms once `setDl` has landed a new state. While the connection is corrupted, + // EVERY attempt arrives tampered and is rejected: the stream stalls at the current chunk, waste piles up, and + // nothing unverified ever reaches the image. Fixing the connection lets the next attempt through and the stream + // resumes where it stalled. useEffect(() => { if (dl.phase !== 'streaming' || !snapshot) return - const delayMs = Math.max(100, Math.min(600, Math.round(STREAM_TARGET_MS / snapshot.chunks.length))) - const perTick = Math.max(1, Math.ceil(snapshot.chunks.length / 60)) const t = setTimeout(() => { const next: DownloadState = { ...dl, verified: new Set(dl.verified) } - let verifiedAny = false - for (let slot = 0; slot < perTick; slot++) { - if (next.cursor >= snapshot.chunks.length) { - next.phase = 'done' - break - } - const idx = next.cursor - const chunk = snapshot.chunks[idx]! - try { - const slice = api.bao_slice(snapshot.outboard, snapshot.bytes, chunk.offset, chunk.len) - next.wireBytes += slice.length - const isRetry = next.retryPending - const buf = new Uint8Array(slice) - // Corrupt fresh fetches only; a retry after a rejection arrives clean, as if from a different mirror. - if (!isRetry && corruptRef.current) buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 - - const v = api.bao_verify_slice(snapshot.rootHex, buf, chunk.offset, chunk.len) - if (v.ok) { - if (assembledRef.current && v.bytes) assembledRef.current.set(v.bytes, chunk.offset) - next.verified.add(idx) - next.verifiedBytes += chunk.len - next.cursor += 1 - next.retryPending = false - verifiedAny = true - } else { - next.wastedBytes += slice.length - next.rejected += 1 - next.retryPending = true - break // the rejection consumes the rest of this tick — a visible stall at the corrupted chunk - } - } catch { - // Slice extraction can only throw on internal errors; treat it like a failed verification rather than - // letting it kill the stream. - next.rejected += 1 - next.retryPending = true - break + const chunk = snapshot.chunks[next.cursor]! + let ok = false + try { + const slice = api.bao_slice(snapshot.outboard, snapshot.bytes, chunk.offset, chunk.len) + next.wireBytes += slice.length + const buf = new Uint8Array(slice) + if (corruptRef.current) buf[buf.length - 1] = (buf[buf.length - 1] ?? 0) ^ 0x01 + const v = api.bao_verify_slice(snapshot.rootHex, buf, chunk.offset, chunk.len) + if (v.ok) { + if (assembledRef.current && v.bytes) assembledRef.current.set(v.bytes, chunk.offset) + ok = true + } else { + next.wastedBytes += slice.length } + } catch { + // Slice extraction can only throw on internal errors; treat it like a rejected attempt. + } + if (ok) { + next.verified.add(next.cursor) + next.verifiedBytes += chunk.len + next.cursor += 1 + next.failing = false + if (next.cursor >= snapshot.chunks.length) next.phase = 'done' + } else { + next.rejected += 1 + next.failing = true } - // Verified bytes form a contiguous prefix (the stream is sequential), so paint only the newly-completed rows. - if (snapshot.ppm && verifiedAny) { + if (snapshot.ppm) { const ctx = canvasRef.current?.getContext('2d') const assembled = assembledRef.current if (ctx && assembled) { - const { width, pixelStart } = snapshot.ppm - const prefixBytes = - next.cursor < snapshot.chunks.length ? snapshot.chunks[next.cursor]!.offset : snapshot.bytes.byteLength - const rowsDone = Math.floor(Math.max(0, prefixBytes - pixelStart) / (width * 3)) - const painted = paintedRowsRef.current - if (rowsDone > painted) { - paintRows(ctx, assembled, snapshot.ppm, painted, rowsDone) - paintedRowsRef.current = rowsDone + if (ok) { + // Verified bytes form a contiguous prefix (the stream is sequential), so paint only the newly-completed + // rows. + const { width, pixelStart } = snapshot.ppm + const prefixBytes = + next.cursor < snapshot.chunks.length ? snapshot.chunks[next.cursor]!.offset : snapshot.bytes.byteLength + const rowsDone = Math.floor(Math.max(0, prefixBytes - pixelStart) / (width * 3)) + if (rowsDone > paintedRowsRef.current) { + paintRows(ctx, assembled, snapshot.ppm, paintedRowsRef.current, rowsDone) + paintedRowsRef.current = rowsDone + } + } else { + // Stall marker at the first unpainted row; the resumed stream's paint overwrites it. + ctx.fillStyle = 'rgba(220,38,38,0.85)' + ctx.fillRect(0, paintedRowsRef.current, snapshot.ppm.width, 2) } } } - // Stall band on the verified canvas: marks the row where the rejected chunk would have painted. The next - // tick's clean retry paints starting at `paintedRowsRef.current` (unchanged since this tick, because the - // rejection broke the loop before advancing it) via the block above, overwriting this band. - if (snapshot.ppm && next.retryPending) { - const ctx = canvasRef.current?.getContext('2d') - if (ctx) { - ctx.fillStyle = 'rgba(220,38,38,0.85)' - ctx.fillRect(0, paintedRowsRef.current, snapshot.ppm.width, 2) - } - } - setDl(next) - }, delayMs) + }, TICK_MS) return () => clearTimeout(t) }, [dl, snapshot, api]) @@ -285,7 +266,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { // Arming the toggle while idle would otherwise be invisible until the next Start — restart so the effect is // immediate. corruptRef is written by the render this setCorrupt triggers, and the first tick fires no sooner than - // `delayMs` (>=100ms) later, so start()'s fresh snapshot is in place well before corruptRef is read. + // `TICK_MS` later, so start()'s fresh snapshot is in place well before corruptRef is read. const toggleCorrupt = () => { const next = !corrupt setCorrupt(next) @@ -297,7 +278,7 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { {snapshot ? (

      Bao root: {snapshot.rootHex.slice(0, 16)}… · outboard {formatBytes(snapshot.outboard.length)} (~6% of the - file) · streams the file as it was at Start + file)

      ) : null} {snapshot ? ( @@ -322,13 +303,14 @@ function StreamingVerifiedDownload({ file }: { file: FileAsset }) { totalLen={snapshot.bytes.byteLength} ariaLabel='Verified download chunks' verifiedSet={dl.verified} - pendingSet={dl.phase === 'streaming' && !dl.retryPending ? new Set([dl.cursor]) : undefined} - failedSet={dl.retryPending ? new Set([dl.cursor]) : undefined} + pendingSet={dl.phase === 'streaming' && !dl.failing ? new Set([dl.cursor]) : undefined} + failedSet={dl.failing ? new Set([dl.cursor]) : undefined} /> ) : null} - {dl.retryPending ? ( + {dl.failing ? (

      - Chunk {dl.cursor} arrived corrupted — rejected by the verifier (hash mismatch). Re-fetching… + Chunk {dl.cursor} keeps arriving corrupted — rejected every attempt (hash mismatch). The stream is stalled; + nothing unverified reaches the image. Fix the connection to resume.

      ) : null} {snapshot ? (