diff --git a/docs/architecture.md b/docs/architecture.md index 9362886..0612f74 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -362,7 +362,13 @@ decide deliberately and document here. cost ~45 ns/byte and capped host→guest byte traffic at ~22 MB/s). Stream payload copies share these paths, and u8 stream chunks stay `Uint8Array` through host buffers too, so a host-side stream read costs exactly the one - rendezvous copy. + rendezvous copy. Lists of the other flat element types (bool, s8, + u16–u64/s16–s64, f32/f64) keep their plain-array host shapes but also copy + bulk, through TypedArray views with the deterministic profile's NaN + canonicalization preserved in both directions (issue #67); the platform's + little-endianness is a named assumption checked once, with the DataView + per-element path as the big-endian fallback. `char` stays per-element (its + lift is per-element USV validation). - **Memory views** are re-acquired after any call that can grow memory (`ArrayBuffer` detach on `memory.grow`). - **Resources.** Host-facing handles are classes with `Symbol.dispose` diff --git a/examples/guests/stream-pass/src/lib.rs b/examples/guests/stream-pass/src/lib.rs index 275eaf4..55bfb3d 100644 --- a/examples/guests/stream-pass/src/lib.rs +++ b/examples/guests/stream-pass/src/lib.rs @@ -27,6 +27,19 @@ impl Guest for Component { input } + async fn take(mut input: StreamReader, count: u32) -> u64 { + let mut sum = 0u64; + for _ in 0..count { + match input.next().await { + Some(b) => sum += u64::from(b), + None => break, + } + } + sum + // `input` drops here with the remainder unread: the host's parked + // write settles short ("reader went away"), cleanly. + } + async fn consume_then_trap(mut input: StreamReader, count: u32) { for _ in 0..count { let _ = input.next().await; diff --git a/examples/guests/stream-pass/wit/world.wit b/examples/guests/stream-pass/wit/world.wit index 9646ef2..438e77d 100644 --- a/examples/guests/stream-pass/wit/world.wit +++ b/examples/guests/stream-pass/wit/world.wit @@ -16,6 +16,11 @@ world stream-pass { /// Result-position pass-through for a non-numeric element type. export pass-through-text: async func(input: stream) -> stream; + /// Reads exactly `count` elements off `input` (leaving the rest unread), + /// returns their sum, and drops the reader — the bounded-consumption probe + /// behind host-side partial-write coverage (#67 checklist / #63 review F3). + export take: async func(input: stream, count: u32) -> u64; + /// Reads `count` elements off `input`, then traps (unreachable) — the #66 /// shape: the stream's readable end dies inside this instance's poisoned /// handle table while host writes are still parked. diff --git a/runtime/src/cabi/bulk_lists.ts b/runtime/src/cabi/bulk_lists.ts new file mode 100644 index 0000000..1efb198 --- /dev/null +++ b/runtime/src/cabi/bulk_lists.ts @@ -0,0 +1,205 @@ +// Bulk (TypedArray-backed) list copies for flat element types (issue #67). +// +// The per-element interpreted `load()`/`store()` costs ~13-45 ns/element +// (despecialize + asserts + DataView per element); these helpers replace the +// loop body with one typed-array view per list and a tight per-element pass +// that preserves the interpreted path's EXACT observable semantics: +// +// * integers: the same `assert_` texts as `storeInt` (`"int store"`, +// `"64-bit store requires bigint"`), the same wrap-on-overflow (a +// TypedArray element write coerces exactly like the matching DataView +// setter), the same number/bigint host shapes on lift; +// * floats: the deterministic profile's NaN canonicalization on BOTH +// directions (float.ts `decodeI32AsFloat` / `encodeFloatAsI32`): every +// lifted NaN becomes the JS canonical NaN, every stored `number` NaN +// writes the canonical bit pattern. Non-NaN values round-trip bit-exactly +// (Float32Array narrowing is the same IEEE round-to-nearest-even as +// `DataView.setFloat32`); +// * bool: store normalizes any value by truthiness to 0/1 (`store()`'s +// `Number(Boolean(v))`), lift maps any nonzero byte to `true` +// (`convertIntToBool` semantics — it never traps for unsigned bytes). +// +// u8 is NOT here: it has its own, shape-changing fast path (`list` is +// `Uint8Array` on the host — load.ts/store.ts). char is NOT here: its lift +// validates USVs per element (`convertI32ToChar` traps), which is the cost. +// +// NAMED ASSUMPTION (issue #67): wasm linear memory is little-endian by spec; +// JS TypedArrays follow the PLATFORM's endianness. Every engine deltic +// targets runs little-endian, but rather than bake that in silently, the +// check below gates the fast paths — on a big-endian platform they decline +// and the callers keep the (endianness-correct) DataView per-element loops. +// +// Alignment: the canonical ABI guarantees list pointers are element-aligned, +// and real guest memories sit at byteOffset 0, so the view construction +// below virtually never declines; a misaligned combination (possible for a +// test MemInst wrapping a subarray) falls back the same way. + +import { assert_ } from "./trap.ts"; +import { bytesOf, type MemInst } from "./memory.ts"; +import type { ComponentValue } from "./types.ts"; +import { CANONICAL_FLOAT32_NAN, CANONICAL_FLOAT64_NAN } from "./float.ts"; + +export const PLATFORM_LITTLE_ENDIAN: boolean = + new Uint8Array(new Uint32Array([0x11223344]).buffer)[0] === 0x44; + +type IntArrayCtor = + | Int8ArrayConstructor + | Uint16ArrayConstructor + | Int16ArrayConstructor + | Uint32ArrayConstructor + | Int32ArrayConstructor; + +const INT_CTORS: Record = { + s8: Int8Array, + u16: Uint16Array, + s16: Int16Array, + u32: Uint32Array, + s32: Int32Array, +}; + +const BIG_CTORS: Record< + string, + BigUint64ArrayConstructor | BigInt64ArrayConstructor +> = { + u64: BigUint64Array, + s64: BigInt64Array, +}; + +const FLOAT_CTORS: Record< + string, + Float32ArrayConstructor | Float64ArrayConstructor +> = { + f32: Float32Array, + f64: Float64Array, +}; + +/** Kinds these helpers handle (besides them, u8 has its own path). */ +export function isBulkListKind(kind: string): boolean { + return kind === "bool" || kind in INT_CTORS || kind in BIG_CTORS || + kind in FLOAT_CTORS; +} + +function viewOf< + C extends { new (b: ArrayBufferLike, o: number, n: number): InstanceType; readonly BYTES_PER_ELEMENT: number }, +>(ctor: C, mem: MemInst, ptr: number, length: number): InstanceType | null { + if (!PLATFORM_LITTLE_ENDIAN) return null; + const byteOffset = mem.bytes.byteOffset + ptr; + if (byteOffset % ctor.BYTES_PER_ELEMENT !== 0) return null; + return new ctor(mem.bytes.buffer, byteOffset, length); +} + +/** + * Bulk lift of `length` elements of `kind` at `ptr`. Returns `null` when the + * kind is not handled here (caller falls back to the per-element loop) — + * never for a handled kind on a little-endian platform with an aligned view. + * The caller has already trap-checked alignment and bounds. + */ +export function tryLoadNumericList( + mem: MemInst, + ptr: number, + length: number, + kind: string, +): ComponentValue[] | null { + if (kind === "bool") { + const out = new Array(length); + const bytes = bytesOf(mem, ptr, length); // range assert: defense-in-depth + for (let i = 0; i < length; i++) out[i] = bytes[i] !== 0; + return out; + } + const intCtor = INT_CTORS[kind]; + if (intCtor !== undefined) { + const view = viewOf(intCtor, mem, ptr, length); + if (view === null) return null; + // Manual preallocated loop: measurably faster than Array.from(view). + const out = new Array(length); + for (let i = 0; i < length; i++) out[i] = view[i]; + return out; + } + const bigCtor = BIG_CTORS[kind]; + if (bigCtor !== undefined) { + const view = viewOf(bigCtor, mem, ptr, length); + if (view === null) return null; + const out = new Array(length); + for (let i = 0; i < length; i++) out[i] = view[i]; + return out; + } + const floatCtor = FLOAT_CTORS[kind]; + if (floatCtor !== undefined) { + const view = viewOf(floatCtor, mem, ptr, length); + if (view === null) return null; + const out = new Array(length); + for (let i = 0; i < length; i++) { + const v = view[i]; + // decodeI32AsFloat/decodeI64AsFloat: every NaN lifts as the canonical + // one (the JS NaN literal IS the canonical f64 NaN, and the canonical + // f32 NaN widens to it exactly). + out[i] = v === v ? v : NaN; + } + return out; + } + return null; +} + +/** + * Bulk store of `v` as elements of `kind` at `ptr`. Returns false when not + * handled (caller falls back). The caller has already trap-checked alignment + * and bounds. + */ +export function tryStoreNumericList( + mem: MemInst, + v: ArrayLike, + ptr: number, + kind: string, +): boolean { + const n = v.length; + if (kind === "bool") { + const bytes = bytesOf(mem, ptr, n); // range assert: defense-in-depth + for (let i = 0; i < n; i++) bytes[i] = v[i] ? 1 : 0; + return true; + } + const intCtor = INT_CTORS[kind]; + if (intCtor !== undefined) { + const view = viewOf(intCtor, mem, ptr, n); + if (view === null) return false; + for (let i = 0; i < n; i++) { + const x = v[i]; + assert_(typeof x === "number" && Number.isInteger(x), "int store"); + view[i] = x as number; // wraps exactly like the DataView setter + } + return true; + } + const bigCtor = BIG_CTORS[kind]; + if (bigCtor !== undefined) { + const view = viewOf(bigCtor, mem, ptr, n); + if (view === null) return false; + for (let i = 0; i < n; i++) { + const x = v[i]; + assert_(typeof x === "bigint", "64-bit store requires bigint"); + view[i] = x as bigint; // wraps mod 2^64 like setBigUint64/setBigInt64 + } + return true; + } + const floatCtor = FLOAT_CTORS[kind]; + if (floatCtor !== undefined) { + const view = viewOf(floatCtor, mem, ptr, n); + if (view === null) return false; + const size = floatCtor.BYTES_PER_ELEMENT; + for (let i = 0; i < n; i++) { + const x = v[i]; + if (typeof x === "number" && Number.isNaN(x)) { + // encodeFloatAsI32/encodeFloatAsI64: a number NaN stores the + // canonical bit pattern, never the engine's. + if (size === 4) { + mem.view.setUint32(ptr + i * 4, CANONICAL_FLOAT32_NAN, true); + } else { + mem.view.setBigUint64(ptr + i * 8, CANONICAL_FLOAT64_NAN, true); + } + } else { + // Same ToNumber coercion + IEEE narrowing as DataView.setFloat*. + view[i] = x as number; + } + } + return true; + } + return false; +} diff --git a/runtime/src/cabi/load.ts b/runtime/src/cabi/load.ts index bf3de43..673c696 100644 --- a/runtime/src/cabi/load.ts +++ b/runtime/src/cabi/load.ts @@ -12,6 +12,7 @@ import { } from "./layout.ts"; import { convertI32ToChar, loadString } from "./strings.ts"; import { type LiftLowerContext, requireMemory } from "./context.ts"; +import { tryLoadNumericList } from "./bulk_lists.ts"; import { liftBorrow, liftOwn } from "./handles.ts"; import { type CaseType, @@ -130,10 +131,17 @@ export function loadListFromValidRange( elemType: ValType, ): ComponentValue { const mem = requireMemory(cx.opts); + const kind = despecialize(elemType).kind; // docs/architecture.md §7: list lifts to a Uint8Array copy. - if (despecialize(elemType).kind === "u8") { + if (kind === "u8") { return bytesOf(mem, ptr, length).slice(); } + // Other flat element types lift bulk too (issue #67) — same host shapes + // (number[]/bigint[]/boolean[]), same NaN canonicalization; falls through + // to the per-element loop for compound types, char (per-element USV + // validation is the point), and non-little-endian platforms. + const bulk = tryLoadNumericList(mem, ptr, length, kind); + if (bulk !== null) return bulk; const size = elemSize(elemType, mem.ptrType()); const a: ComponentValue[] = []; for (let i = 0; i < length; i++) { diff --git a/runtime/src/cabi/mod.ts b/runtime/src/cabi/mod.ts index eb4f723..bec6ce9 100644 --- a/runtime/src/cabi/mod.ts +++ b/runtime/src/cabi/mod.ts @@ -14,6 +14,7 @@ export * from "./float.ts"; export * from "./context.ts"; export * from "./handles.ts"; export * from "./strings.ts"; +export * from "./bulk_lists.ts"; export * from "./load.ts"; export * from "./store.ts"; export * from "./flatten.ts"; diff --git a/runtime/src/cabi/store.ts b/runtime/src/cabi/store.ts index f13a996..f044790 100644 --- a/runtime/src/cabi/store.ts +++ b/runtime/src/cabi/store.ts @@ -2,6 +2,7 @@ import { assert_, NotImplemented, trapIf } from "./trap.ts"; import { bytesOf, storeInt, storePtr } from "./memory.ts"; +import { tryStoreNumericList } from "./bulk_lists.ts"; import { encodeFloatAsI32, encodeFloatAsI64 } from "./float.ts"; import { alignment, @@ -163,12 +164,13 @@ export function storeListIntoValidRange( elemType: ValType, ): void { const mem = requireMemory(cx.opts); + const kind = despecialize(elemType).kind; // docs/architecture.md §7: list is Uint8Array-shaped on the host, and // both directions are bulk copies — this is the store-side mirror of // load.ts `loadListFromValidRange`'s u8 fast path (issue #54: the // per-element interpreted store cost ~45 ns/byte, capping async imports // returning list at ~22 MB/s while the lift ran at memcpy speed). - if (despecialize(elemType).kind === "u8") { + if (kind === "u8") { const dst = bytesOf(mem, ptr, v.length); if (v instanceof Uint8Array) { dst.set(v); @@ -184,6 +186,11 @@ export function storeListIntoValidRange( } return; } + // Other flat element types store bulk too (issue #67), preserving the + // per-element semantics exactly (same asserts, same wrap, canonical-NaN + // floats); falls through for compound types, char, and non-little-endian + // platforms. + if (tryStoreNumericList(mem, v, ptr, kind)) return; const size = elemSize(elemType, mem.ptrType()); for (let i = 0; i < v.length; i++) { store(cx, v[i], elemType, ptr + i * size); diff --git a/runtime/tests/bulk_list_test.ts b/runtime/tests/bulk_list_test.ts new file mode 100644 index 0000000..7ac22f2 --- /dev/null +++ b/runtime/tests/bulk_list_test.ts @@ -0,0 +1,248 @@ +// Bulk list copies for flat element types (issue #67; cabi/bulk_lists.ts). +// These tests pin EQUIVALENCE with the per-element interpreted path: same +// host shapes, same wrap-on-overflow, same assert texts, and the +// deterministic profile's canonical-NaN handling in both directions +// (float.ts; run against absolute expected byte patterns, not against the +// old code path). + +import { + type ComponentValue, + load, + MemInst, + PLATFORM_LITTLE_ENDIAN, + store, + tryLoadNumericList, + tryStoreNumericList, + type ValType, +} from "../src/cabi/mod.ts"; +import { mkCx } from "./support/driver.ts"; +import { Heap } from "./support/heap.ts"; +import { assertEq } from "./support/asserts.ts"; + +function listOf(kind: string): ValType { + return { kind: "list", element: { kind } as ValType } as ValType; +} + +function cxWithHeap(size: number) { + const heap = new Heap(size); + return { + heap, + cx: mkCx(new MemInst(heap.memory, "i32"), "utf8", heap.realloc), + }; +} + +/** Store a list at 8 (heap pre-bumped clear of it), read back bytes+value. */ +function storeAndReadBack( + v: ComponentValue, + t: ValType, + size = 4096, +): { bytes: Uint8Array; lifted: ComponentValue } { + const { heap, cx } = cxWithHeap(size); + heap.lastAlloc = 16; + store(cx, v, t, 8); + const begin = heap.memory[8] | (heap.memory[9] << 8) | + (heap.memory[10] << 16) | (heap.memory[11] << 24); + const length = heap.memory[12] | (heap.memory[13] << 8) | + (heap.memory[14] << 16) | (heap.memory[15] << 24); + const elemBytes = { // by kind, for slicing the payload + bool: 1, + s8: 1, + u16: 2, + s16: 2, + u32: 4, + s32: 4, + f32: 4, + u64: 8, + s64: 8, + f64: 8, + }[(t as { element: { kind: string } }).element.kind]!; + return { + bytes: heap.memory.slice(begin, begin + length * elemBytes), + lifted: load(cx, 8, t), + }; +} + +Deno.test("bulk lists: integer kinds round-trip and wrap like the DataView setters", () => { + const cases: [string, ComponentValue[], ComponentValue[]][] = [ + // [kind, stored, expected lift] + ["u16", [0, 1, 0xffff, 0x1_0005], [0, 1, 0xffff, 5]], + ["s16", [-1, -32768, 32767, 0x1_0005], [-1, -32768, 32767, 5]], + ["u32", [0, 0xffffffff, 1], [0, 0xffffffff, 1]], + ["s32", [-1, -2147483648, 2147483647], [-1, -2147483648, 2147483647]], + ["s8", [-1, -128, 127, 200], [-1, -128, 127, -56]], + ]; + for (const [kind, stored, expected] of cases) { + const { lifted } = storeAndReadBack(stored, listOf(kind)); + assertEq(lifted, expected, `${kind} round-trip+wrap`); + } +}); + +Deno.test("bulk lists: 64-bit kinds are bigint-shaped and wrap mod 2^64", () => { + const u = storeAndReadBack( + [0n, 0xffffffffffffffffn, 1n, (1n << 64n) + 7n], + listOf("u64"), + ); + assertEq(u.lifted, [0n, 0xffffffffffffffffn, 1n, 7n], "u64"); + const s = storeAndReadBack( + [-1n, -(1n << 63n), (1n << 63n) - 1n], + listOf("s64"), + ); + assertEq(s.lifted, [-1n, -(1n << 63n), (1n << 63n) - 1n], "s64"); +}); + +Deno.test("bulk lists: bool normalizes by truthiness, lifts nonzero as true", () => { + // store(): `Number(Boolean(v))` accepted ANY value — pin that. + const { bytes, lifted } = storeAndReadBack( + [true, false, 2 as unknown as ComponentValue, "" as unknown as ComponentValue], + listOf("bool"), + ); + assertEq([...bytes], [1, 0, 1, 0], "stored bytes normalized"); + assertEq(lifted, [true, false, true, false]); + // A raw nonzero byte in memory lifts true (convertIntToBool semantics). + const { heap, cx } = cxWithHeap(64); + heap.memory[8] = 2; + heap.memory[9] = 0; + const t: ValType = { + kind: "list", + element: { kind: "bool" }, + length: 2, + } as ValType; + assertEq(load(cx, 8, t), [true, false], "byte 2 lifts as true"); +}); + +Deno.test("bulk lists: floats round-trip, f32 narrows like setFloat32", () => { + const f64 = storeAndReadBack([0.5, -0, 1e308, 5e-324], listOf("f64")); + assertEq(f64.lifted, [0.5, -0, 1e308, 5e-324], "f64 exact"); + const f32 = storeAndReadBack([0.5, 1.1, -3.4028234663852886e38], listOf("f32")); + assertEq( + f32.lifted, + [0.5, Math.fround(1.1), -3.4028234663852886e38], + "f32 IEEE narrowing", + ); +}); + +Deno.test("bulk lists: NaN stores canonical bit patterns (deterministic profile)", () => { + const f32 = storeAndReadBack([NaN, 1], listOf("f32")); + assertEq( + [...f32.bytes.subarray(0, 4)], + [0x00, 0x00, 0xc0, 0x7f], + "canonical f32 NaN bits", + ); + const f64 = storeAndReadBack([NaN], listOf("f64")); + assertEq( + [...f64.bytes], + [0, 0, 0, 0, 0, 0, 0xf8, 0x7f], + "canonical f64 NaN bits", + ); +}); + +Deno.test("bulk lists: payload NaNs in memory lift as canonical NaN and re-store canonical", () => { + // Craft a non-canonical (payload) NaN in memory, lift, and re-store: the + // deterministic profile forces the canonical pattern both ways. + const { heap, cx } = cxWithHeap(64); + const view = new DataView(heap.memory.buffer); + view.setUint32(8, 0x7fc00001, true); // payload f32 NaN + const t: ValType = { + kind: "list", + element: { kind: "f32" }, + length: 1, + } as ValType; + const lifted = load(cx, 8, t) as number[]; + assertEq(Number.isNaN(lifted[0]), true, "lifts as NaN"); + store(cx, lifted, t, 12); + assertEq( + [...heap.memory.subarray(12, 16)], + [0x00, 0x00, 0xc0, 0x7f], + "re-stored canonical", + ); + // f64 twin (decodeI64AsFloat parity): a signaling-shaped payload NaN. + view.setBigUint64(16, 0x7ff0000000000001n, true); + const t64: ValType = { + kind: "list", + element: { kind: "f64" }, + length: 1, + } as ValType; + const lifted64 = load(cx, 16, t64) as number[]; + assertEq(Number.isNaN(lifted64[0]), true, "f64 payload NaN lifts as NaN"); + store(cx, lifted64, t64, 24); + assertEq( + [...heap.memory.subarray(24, 32)], + [0, 0, 0, 0, 0, 0, 0xf8, 0x7f], + "f64 re-stored canonical", + ); +}); + +Deno.test("bulk lists: the fast path actually fires on this platform", () => { + // All equivalence tests above pass identically under full fallback; this + // smoke catches an accidental always-decline regression (review advisory). + if (!PLATFORM_LITTLE_ENDIAN) return; // BE platforms legitimately decline + const { heap, cx } = cxWithHeap(64); + void cx; + const mem = new MemInst(heap.memory, "i32"); + assertEq( + tryLoadNumericList(mem, 0, 4, "u32") !== null, + true, + "u32 bulk lift engaged", + ); + assertEq( + tryStoreNumericList(mem, [1, 2], 0, "u16"), + true, + "u16 bulk store engaged", + ); +}); + +Deno.test("bulk lists: validation asserts match the per-element path", () => { + for ( + const [kind, bad, msg] of [ + ["u32", [1.5], "int store"], + ["u32", [Infinity], "int store"], + ["u32", [NaN], "int store"], + ["u32", [1n], "int store"], + ["s16", ["7"], "int store"], + ["u64", [1], "64-bit store requires bigint"], + ["s64", [1.5], "64-bit store requires bigint"], + ] as [string, unknown[], string][] + ) { + let err: unknown; + try { + storeAndReadBack(bad as ComponentValue[], listOf(kind)); + } catch (e) { + err = e; + } + assertEq( + String(err).includes(msg), + true, + `${kind} ${Deno.inspect(bad)}: expected '${msg}', got: ${err}`, + ); + } +}); + +Deno.test("bulk lists: empty and fixed-length lists", () => { + assertEq(storeAndReadBack([], listOf("u32")).lifted, []); + const { heap, cx } = cxWithHeap(64); + const t: ValType = { + kind: "list", + element: { kind: "u32" }, + length: 3, + } as ValType; + store(cx, [7, 8, 9], t, 8); + assertEq(load(cx, 8, t), [7, 8, 9], "fixed-length round-trip"); + assertEq(heap.memory[8], 7, "payload in place (no indirection)"); +}); + +Deno.test("bulk lists: misaligned view falls back to the per-element path, same results", () => { + // A MemInst over a subarray with odd byteOffset makes the typed-array view + // construction decline (byteOffset+ptr not element-aligned) while the CABI + // alignment of ptr itself is satisfied — the fallback must produce + // identical results. + const backing = new Uint8Array(128); + const mem = new MemInst(backing.subarray(2), "i32"); + const heapless = mkCx(mem, "utf8", null); + const t: ValType = { + kind: "list", + element: { kind: "u32" }, + length: 2, + } as ValType; + store(heapless, [0x11223344, 0xffffffff], t, 4); + assertEq(load(heapless, 4, t), [0x11223344, 0xffffffff], "fallback parity"); +}); diff --git a/runtime/tests/embedder/passthrough_test.ts b/runtime/tests/embedder/passthrough_test.ts index 5ad87a6..92cd47c 100644 --- a/runtime/tests/embedder/passthrough_test.ts +++ b/runtime/tests/embedder/passthrough_test.ts @@ -145,6 +145,33 @@ Deno.test({ }, }); +Deno.test({ + name: "guest-side partial take: a bounded reader drains part of a big typed offer", + ignore: !ready, + fn: async () => { + // #63 review F3 / #67 checklist: the host offers far more than the guest + // consumes, exercising GuestBuffer partial rendezvous + the writeAll + // re-offer rounds against a REAL guest (wit-bindgen buffering means the + // guest's stream.reads may take more than `count` — the writer's total + // is only bounded, not exact). `take` returns the sum of the `count` + // elements it consumed, so data integrity is pinned exactly even though + // the take count is not. + const c = await instantiateFixture(FIXTURE, { sink: () => 0n }); + const { stream, writer } = Stream.create(); + const N = 256 * 1024; + const data = Uint8Array.from({ length: N }, (_, i) => (i * 31) & 0xff); + const expected = BigInt(data[0] + data[1] + data[2]); + const w = writer.writeAll(data); + assertEq(await c.exports.take(stream, 3), expected, "sum of first 3"); + const taken = await w; + assertEq( + taken >= 3 && taken < N, + true, + `writeAll settles short of the offer (took ${taken})`, + ); + }, +}); + Deno.test({ name: "futures: wrapping one shared future is idempotent too", ignore: false,