feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, view. TeeSink. - #31
feat(core): add I/O contracts — ByteQueue, BufferedSource/Sink, view. TeeSink.#31Wahbeh-Mohammad wants to merge 2 commits into
Conversation
| */ | ||
| writeBytes(bytes: Uint8Array): void { | ||
| if (bytes.length === 0) return; | ||
| this.#append(bytes.slice()); |
There was a problem hiding this comment.
bytes.slice() is not a copy when handed a Node Buffer — Buffer.prototype.slice returns an aliasing view — so IO-30's mandated independent copy and the Chunk.bytes "never mutated after linked in" invariant that makes #moveTo's zero-copy subarray transfers safe are both false.
Verified on Node v26: const buf = Buffer.from('SECRET'); q.writeBytes(buf); buf.fill(0x58); q.snapshot() returns XXXXXX. IO-30's own named conformance test ("wrapping a byte array then mutating the input leaves the source unchanged") fails. Worse with pooled buffers, which is what socket reads hand you: pool.allocUnsafe(32), enqueue pool.subarray(0,10), then reuse the pool -> a chunk already retained behind live peek/slice cursors reads OVERWRITTE instead of FIRSTCHUNK. Same call in BufferedSource.overBytes (buffered-source.ts:62), BufferedSink.toWritableStream (buffered-sink.ts:90) and bufferedSinkOverPrimitive (factories.ts:94). Fix: Uint8Array.prototype.slice.call(bytes) or new Uint8Array(bytes).
| this.#remainingBudget(), | ||
| ); | ||
| if (available > searched) { | ||
| const scanned = this.#window.peekBytes(this.#cursor, available); |
There was a problem hiding this comment.
#scanForNewline re-materializes the entire scanned prefix on every pulled chunk (and peekBytes copies it twice), making readUtf8Line() O(n^2) in bytes copied with no line-length bound.
Measured on the PR head with 1-byte chunks: a 2000-byte line takes 136 ms, 4000 -> 520 ms, 8000 -> 1972 ms, 16000 -> 9758 ms — exactly 4x time per 2x input. readUtf8Line is the primitive for header and chunked-encoding parsing over attacker-controlled bytes, so a peer that dribbles a long newline-free line pins a CPU core; RetentionWindow is uncapped so it also buffers the whole thing. The searched counter already tracks progress — only the newly pulled tail needs peeking.
| close: async (): Promise<void> => { | ||
| await this.close(); | ||
| }, | ||
| abort: async (): Promise<void> => { |
There was a problem hiding this comment.
toWritableStream()'s abort handler calls this.close(), converting an abort into a graceful close of the underlying writable and discarding the abort reason.
Verified: const w = sink.toWritableStream().getWriter(); await w.write(utf8('partial')); await w.abort(new Error('user cancelled')) makes the underlying stream observe ['write:partial', 'close'] — its abort(reason) callback never fires. A cancelled request body is committed downstream as a well-formed complete body, so the peer cannot distinguish an aborted upload from a successful short one. Should forward this.#writer.abort(reason); the handler does not even declare reason.
| const normalized = charset.toLowerCase(); | ||
| if (normalized === 'utf-8' || normalized === 'utf8') | ||
| return new TextEncoder().encode(text); | ||
| if (normalized !== 'iso-8859-1' && normalized !== 'latin1') { |
There was a problem hiding this comment.
The write path implements true ISO-8859-1 (code point -> byte for 0-255) but the read path's new TextDecoder('iso-8859-1') resolves that WHATWG label to windows-1252, so IO-13's mandated symmetric encodings do not round-trip.
Verified on Node v26: new TextDecoder('iso-8859-1').encoding === 'windows-1252' (same for latin1). encodeText('\u0080\u0091\u009F','iso-8859-1') emits [128,145,159]; feeding those bytes back through BufferedSource.readString('iso-8859-1') returns [8364, 8216, 376] (EUR, curly quote, Y-diaeresis). The inverse also breaks: a body decoded as iso-8859-1 containing EUR cannot be re-encoded — encodeText throws IoError: code point 8364 is not representable. IO-13's own conformance test is "round-trip non-ASCII text through a non-UTF-8 charset (e.g. ISO-8859-1)".
| this.#assertOpen(); | ||
| if (count === 0) return; | ||
| // takeBytes raises EndOfStreamError when the source is short, before anything reaches the wire. | ||
| const payload = src.takeBytes(count); |
There was a problem hiding this comment.
write() consumes count bytes out of the caller's src via takeBytes before the downstream write is known to succeed, so a failed write destroys the payload with no way to retry.
Verified: q.writeBytes(utf8('IMPORTANT-BODY')); await sink.write(q, q.size) against a writer that rejects leaves q.size === 0 and nothing on the wire. TeeSink.write (tee-sink.ts:55) inherits it via staging.write(src, count) and is worse — verified src.size = 0, wire got 0 bytes, but tee.snapshot() reports all 5 bytes as written, so the captured log claims a body reached the wire when none did. The comment at tee-sink.ts:51-52 promises the opposite ("a caller that catches the rejection still holds its bytes"), which only holds for the closed-primary check one line above.
| } | ||
| const line = await this.readExactly(at + 1); | ||
| const end = at > 0 && line[at - 1] === CARRIAGE_RETURN ? at - 1 : at; | ||
| return new TextDecoder('utf-8').decode(line.subarray(0, end)); |
There was a problem hiding this comment.
A fresh TextDecoder is constructed per decoded fragment with ignoreBOM defaulting to false, so a leading U+FEFF is stripped from every line and from every count-based read — not just at stream start.
Verified: for the document 'a\n\uFEFFb\n\uFEFFc', successive readUtf8Line() calls return ['a','b','c'] — the U+FEFF opening lines 2 and 3 is silently deleted. readUtf8() on '\uFEFFpayload' returns 'payload' (7 chars), so a body's first three bytes vanish, breaking content hashing, signature verification and exact-length assertions. Affects buffered-source.ts:145 (readString/readUtf8), :169 and :173 (readUtf8Line). Fix: new TextDecoder(charset, {ignoreBOM: true}), stripping a BOM explicitly at offset 0 only if desired.
| export function bufferedSourceOverPrimitive( | ||
| source: PrimitiveSource, | ||
| ): BufferedSource { | ||
| const staging = new ByteQueue(); |
There was a problem hiding this comment.
bufferedSourceOverPrimitive hoists one staging ByteQueue into the closure shared by every pull(), drains it with takeBytes(read) rather than takeBytes(staging.size), and calls controller.close() without draining the residue.
Verified: a PrimitiveSource appending 4 bytes but returning 2 per call, twice, then END_OF_STREAM, yields [10,11,12,13] from readBytes() — 8 bytes appended, 4 emitted, 4 lost with no error and no SourceContractViolationError. Because takeBytes drains from the HEAD, residue also reorders output: the second pull returns the first pull's leftovers. The sibling bufferedSinkOverPrimitive (line 93) correctly allocates a fresh queue per call, so the asymmetry looks unintentional.
| * | ||
| * @internal | ||
| */ | ||
| export class TeeSink { |
There was a problem hiding this comment.
TeeSink is documented as a BufferedSink decorator but is not assignable to BufferedSink — it omits closed and toWritableStream, and BufferedSink's #private fields make the type nominal — so it cannot be used anywhere a sink is accepted.
Verified with tsc against the PR head: writeAll(source, new TeeSink(primary)) fails with TS2345: Argument of type 'TeeSink' is not assignable to parameter of type 'BufferedSink'. Type 'TeeSink' is missing the following properties: #writer, #closed, closed, toWritableStream, #assertOpen. new TeeSink(anotherTee) fails identically, so tees cannot nest. writeAll is the only pump in the package and body capture is exactly what IO-25 exists for; a caller who needs a bridge must keep the primary and call primary.toWritableStream(), and every byte written through it silently bypasses the tap. The fix belongs one layer down: an internal Sink interface both classes implement, with writeAll and TeeSink's constructor typed against it.
| this.#queue.close(); | ||
| // The reader lock must be released even if the stream already errored; a rejection here would | ||
| // otherwise become an unhandled rejection on a teardown path. | ||
| void this.#reader?.cancel().catch(() => undefined); |
There was a problem hiding this comment.
void this.#reader?.cancel().catch(() => undefined) detaches teardown from close() and swallows every cancel failure, so await source.close() resolves before the reader is released and reports success even when release failed.
Verified two cases. (a) A stream whose cancel() awaits 20 ms then records: await source.close() returns first — observed order ['close-returned', 'underlying-cancel-finished'] — so a caller sequencing connection reuse or shutdown on await close() races a still-open reader. (b) A stream whose cancel() throws new Error('socket teardown failed'): await source.close() resolves with no error and nothing is recorded anywhere. BufferedSource.close() is already async (buffered-source.ts:265) and ends with a pointless return Promise.resolve(), so it can await the real cancel instead.
| const read = await this.read(staging, BRIDGE_CHUNK); | ||
| if (read === END_OF_STREAM) { | ||
| controller.close(); | ||
| await this.close(); |
There was a problem hiding this comment.
toReadableStream()'s pull calls this.close() on reaching EOF, which for an owning source closes the whole RetentionWindow and invalidates every outstanding peek/slice view.
Verified: const preview = source.peek(); const rs = source.toReadableStream(); await new Response(rs).arrayBuffer(); then await preview.readBytes() throws ClosedResourceError: BufferedSource is closed. This defeats IO-19's stated rationale ("repeatable body reads (logging previews, response replay)") for the most natural usage — take a preview, hand the bridge to the transport, read the preview afterwards. IO-16 only requires that closing the bridge close the source; auto-closing at natural EOF is an extra step. The doc comment on line 280 is also wrong for a view, where close() only releases the cursor.
| } | ||
| // `done: false` narrows `value` to a defined `Uint8Array` per the Streams spec's discriminated | ||
| // union — no runtime check needed on top of what the type already guarantees. | ||
| if (value.length === 0) { |
There was a problem hiding this comment.
value.length is dereferenced on the strength of a compile-time narrowing with no runtime guard, so a caller-supplied stream that is only nominally <Uint8Array> escapes as a raw TypeError outside the IoError tree, or corrupts the queue silently.
Verified: (a) a stream enqueuing undefined crashes with TypeError: undefined is not an object (evaluating 'value.length'); (b) a string chunk — what Readable.toWeb() yields when the Node stream has an encoding set — passes straight through because 'abc'.length is 3 and ByteQueue.writeBytes's bytes.slice() dispatches to String.prototype.slice, so the queue reports size === 3 and only detonates much later with TypeError: chunk.bytes.subarray is not a function. IO-17 requires source-contract violations be raised as I/O errors; a value instanceof Uint8Array check at the boundary is missing.
| */ | ||
| async writeString(text: string, charset: string): Promise<void> { | ||
| this.#assertOpen(); | ||
| await this.#writer.write(encodeText(text, charset)); |
There was a problem hiding this comment.
writeString has no zero-length short-circuit while write(src, count) returns early on count === 0, so an empty string emits a 0-byte chunk on the sink but nothing through the tee or the bridge.
Verified chunk lengths at the underlying WritableStream: BufferedSink.writeUtf8('') -> [0], TeeSink.writeUtf8('') -> [], sink.toWritableStream() writer given an empty chunk -> []. IO-25 requires the tee forward the full untruncated payload to its primary, so the two must produce identical chunk sequences and do not. A zero-length chunk handed to an HTTP/1.1 chunked-encoding transport is the terminating chunk, so writeUtf8('') on an empty body segment can end a request body early — and the behaviour is inconsistent in three directions.
| } | ||
|
|
||
| /** Every remaining byte; empty when already exhausted (IO-11). */ | ||
| async readBytes(): Promise<Uint8Array> { |
There was a problem hiding this comment.
IO-9's ceiling is enforced eagerly in readExactly but nowhere in readBytes, so the count-less read (and readString/readUtf8 with no count, which route through it) drains the whole source into memory before the check fires inside snapshot().
Verified: readBytes()'s body contains no MAX_BYTE_ARRAY_LENGTH or AllocationLimitError reference, while readExactly(MAX_BYTE_ARRAY_LENGTH + 1) refuses without reading a byte. On a 3 GiB response body, readBytes() / readUtf8() loop 16 KiB at a time until the entire 3 GiB sits in the staging ByteQueue, and only then does snapshot() -> #materialize raise AllocationLimitError — the process is far more likely to OOM first, which is precisely the "low-level allocation crash" IO-9 exists to replace with an actionable refusal.
| /** IO-18: a cheap one-level handoff, distinguished from `flush`. */ | ||
| async emit(): Promise<BufferedSink> { | ||
| this.#assertOpen(); | ||
| return Promise.resolve(this); |
There was a problem hiding this comment.
emit() never touches the writer, so it reports success on a sink whose stream has already errored, and flush() awaits writer.ready — a backpressure signal, not the force-out IO-5 requires.
Verified: after a write against a failing stream rejects, await sink.flush() correctly rejects with 'boom' but await sink.emit() RESOLVES — a caller using emit() as its handoff checkpoint gets a green light on a dead stream. Separately verified with a gated underlying write: void sink.write(q, 4); await sink.flush() resolves with delivered === 0 and the write still pending. IO-5 requires flush to push buffered bytes toward the destination; neither method does, so IO-18's flush/emit distinction is unobservable in the direction that matters.
| ); | ||
| } | ||
|
|
||
| get closed(): boolean { |
There was a problem hiding this comment.
The closed getter reads only #closed and ignores the window, so a peek/slice view invalidated by its parent's close reports closed === false while every operation on it throws.
Verified: const v = source.peek(); await source.close(); leaves source.closed === true but v.closed === false, and await v.readBytes() then throws ClosedResourceError. The natural defensive guard if (!view.closed) await view.readBytes() is guaranteed to take the throwing branch, which defeats the point of exposing the flag — and it is the same shape the PR's own tests use (buffered-source.views.test.ts:64). RetentionWindow already exposes closed, so the getter can be this.#closed || this.#window.closed.
OmarAlJarrah
left a comment
There was a problem hiding this comment.
A second pass focused on §5 conformance and on the teardown paths, after the round of inline comments already on this PR. Five things below that aren't covered by those: two lifecycle defects, one comment that documents behaviour the platform doesn't have, and two tests that don't test what they claim.
The rest of the section reads as faithful. readUtf8Line's CRLF-versus-lone-\r handling matches IO-14 exactly (including the empty-line and bare-\n cases), IO-2's zero-count-before-exhaustion ordering is right in both ByteQueue.read and BufferedSource.read, IO-21's lazy slice-offset overflow surfaces as EOF on first read as required, and IO-22/IO-24 invalidation fails loudly rather than serving stale bytes. The deliberate non-implementation of the resolution half of IO-30 and of IO-31–IO-36 is recorded in the checklist and matches the SEAM-5–SEAM-10 decision, so that's consistent.
| /** IO-5, IO-41: closeable and idempotent. */ | ||
| async close(): Promise<void> { | ||
| if (this.#closed) return; | ||
| this.#closed = true; |
There was a problem hiding this comment.
#closed is set before the operation that actually closes the destination is awaited, so a close that fails is unretryable and reports success.
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true; // committed before we know it worked
await this.#writer.close();
}If #writer.close() rejects — a stream that errored, a socket already gone — the first await sink.close() rejects, but #closed is already true. A caller that retries gets the early return and a silent resolve, and sink.closed reads true for a destination that was never released. IO-41 asks that the underlying resource be closed at most once; here it can end up closed zero times while the object insists otherwise.
Setting the flag after the await has the opposite failure (a concurrent second close could re-enter), so the fix is probably a three-state latch — idle / closing / closed — with the closing promise memoized so a second call awaits the first rather than either re-entering or lying.
Worth noting §6 leans on this: BODY-27 requires that a delegate whose close throws still be marked closed and the failure propagate once. That is close to what's written here, but it wants the failure surfaced on every path rather than swallowed by the second caller's early return.
| this.#cursors.clear(); | ||
| this.#queue.clear(); | ||
| this.#queue.close(); | ||
| // The reader lock must be released even if the stream already errored; a rejection here would |
There was a problem hiding this comment.
This comment describes something cancel() does not do. reader.cancel() cancels the stream; it never releases the reader's lock — only releaseLock() does that. Verified on Node 26:
const rs = new ReadableStream({start(c){ c.enqueue(new Uint8Array([1])); }});
const reader = rs.getReader();
await reader.cancel();
rs.locked; // true
rs.getReader(); // TypeError: Invalid state: ReadableStream is lockedSo after await source.close() the caller's ReadableStream stays locked permanently and can never be re-read. Whether that's acceptable is a real question rather than an obvious bug — IO-6 and SEAM-3 say wrapping a caller's stream takes ownership of it, so a consumed stream arguably should not be re-readable. But the code should say what it does. Either drop the lock claim and state the ownership rationale, or add the releaseLock() this comment promises.
This is the line above the detached-teardown comment already on 131, and both are the same repair: whatever close() ends up awaiting should also decide the lock's fate.
| return new ReadableStream<Uint8Array>({ | ||
| pull: async (controller): Promise<void> => { | ||
| const staging = new ByteQueue(); | ||
| const read = await this.read(staging, BRIDGE_CHUNK); |
There was a problem hiding this comment.
The bridge's failure path never releases the source. If this.read() rejects here, the ReadableStream transitions to errored — and cancel is not invoked on an errored stream, so the cancel handler below never runs and this.close() never happens. The reader lock and the retention window are both stranded.
IO-16 requires that closing the bridge close the owning source. The clean-EOF path does that (see the separate note on 293 arguing it does rather too much), and explicit cancel() does it, but a mid-stream read failure — the case that matters most for a connection-backed source — does neither. Wrapping the read in a try/catch that closes and rethrows, or passing the error through controller.error() after closing, would cover it.
The two bridge tests exercise full drain and explicit cancel(); there's no test where the underlying read fails partway.
| // The constant is deliberately conservative across V8 and JavaScriptCore. This asserts we did not | ||
| // pick a number the current host cannot honor; the RangeError backstop in ByteQueue covers hosts | ||
| // whose real ceiling is lower still. | ||
| expect(MAX_BYTE_ARRAY_LENGTH).toBeLessThanOrEqual(2 ** 32 - 1); |
There was a problem hiding this comment.
This test doesn't check what its name and comment say it checks. The title is "a Uint8Array of MAX_BYTE_ARRAY_LENGTH is at or under what the host actually allows" and the comment says it asserts "we did not pick a number the current host cannot honor" — but the body compares two compile-time constants:
expect(MAX_BYTE_ARRAY_LENGTH).toBeLessThanOrEqual(2 ** 32 - 1);2**31 - 1 <= 2**32 - 1 holds by construction. No Uint8Array is allocated, the host is never consulted, and the assertion would pass for any value up to 4 GiB — including values that would fail on every supported runtime, which is precisely the case it's meant to rule out.
Allocating the array is the honest version, though at 2 GiB that's too heavy for the default suite. Given the RangeError backstop in allocate() already handles a host whose real ceiling is lower, the simplest fix may be to delete this one and let the backstop's own test carry the guarantee — a test that can't fail is worse than no test, because it reads as coverage.
| }); | ||
|
|
||
| describe('BufferedSink lifecycle (IO-18, IO-41, IO-42, IO-6)', () => { | ||
| test('IO-18: flush and emit both return the sink for chaining', async () => { |
There was a problem hiding this comment.
This is the only IO-18 test, and it asserts only that both methods return the sink:
expect(await sink.emit()).toBe(sink);
expect(await sink.flush()).toBe(sink);Give emit() the body of flush() or vice versa and this still passes. IO-18 is specifically about the two being distinguishable — emit as a cheap one-level handoff, flush as a full force-out toward the destination — so the requirement has no behavioural coverage here.
A test that shows the difference would need the sink to observe something: e.g. a writable whose write resolves lazily, asserting flush() waits for the destination to drain while emit() returns without doing so. That would also give the separate concern raised on buffered-sink.ts:73 — that flush() awaits writer.ready, a backpressure signal rather than a force-out — a place to land.
OmarAlJarrah
left a comment
There was a problem hiding this comment.
Deeper pass, this time running the branch rather than reading it. Summary first, because most of it is good news.
Everything structural checks out. All ten blocking gates pass on this branch — typecheck, lint, build, api:ci, lint:publish, verify:dual-consumption, verify:seam-1, verify:runtime-floor, audit, and the suite (140 pass, 95% lines). The PR body's claims hold up under checking: core.api.md really is byte-identical to 3-phase-3, there is genuinely no AbortSignal, setTimeout or AbortController anywhere in src/io/ (only prose mentioning their absence), every new file carries SPDX on line 1, and every test file cites its IO-N ids.
I probed the requirements that tests can't easily reach, and they hold. Closing a source while a read is parked mid-reader.read() surfaces ClosedResourceError rather than stale bytes or a hang — IO-38's JS analogue works, via the assertUsable() recheck after each await in pullThrough. IO-37's explicit allowance for concurrent use of independent views works: a parent and a peek read concurrently without cross-talk. IO-23's slice-of-slice composes additively and caps at the outer budget. IO-21's far-offset slice reads empty rather than throwing. IO-3 ordering is right — a negative count on a closed source raises the argument error, not the state error. Line reading survives chunk boundaries, including a UTF-8 sequence split mid-character, and EndOfStreamError reports delivered N of M.
I confirmed the Buffer.slice finding empirically — worth stating plainly because it is the most consequential thing on this PR. Buffer.prototype.slice aliases where Uint8Array.prototype.slice copies, so writeBytes(buf) and overBytes(buf) both leave the caller's buffer wired into the queue: mutate the input afterwards and the queue's contents change. IO-30's independence guarantee is broken for the single most likely input type in a Node SDK.
Three new items below.
| * anything could rewind over them. Peeking leaves the cursor still, so the bytes stay retained, and the | ||
| * subsequent `readExactly` consumes exactly the line plus its terminator. | ||
| */ | ||
| async readUtf8Line(): Promise<string | undefined> { |
There was a problem hiding this comment.
The BOM stripping flagged on line 173 has a consequence beyond this phase: it makes an SSE requirement unimplementable in Phase 6b.
SSE-12 says a single BOM at the very start is consumed once, and then — explicitly — "any BOM later in the stream MUST be preserved as ordinary data." SSE-40 has the reader driving a source through line reads, and SSE-16 keeps it single-pass. So the SSE parser's only view of the bytes is what readUtf8Line() hands it.
Right now every line that begins with U+FEFF loses it, because a fresh TextDecoder is built per line with ignoreBOM defaulting to false. Confirmed on this branch: for 'a\n\uFEFFb\n' the second line comes back as 'b', not '\uFEFFb'. By the time Phase 6b's parser runs, the byte it is required to preserve is already gone, and no amount of care at the SSE layer can recover it.
That turns the ignoreBOM: true fix from a correctness nicety into a prerequisite for §13. Worth a line in the TSDoc here recording why the flag has to stay on, so a later reader does not "tidy" it back off — the SSE requirement is three phases away and the connection is not obvious from this file.
| const queue = new ByteQueue(); | ||
| for (const chunk of input) queue.writeBytes(chunk); | ||
| const before = queue.snapshot(); | ||
| const expected = [...before]; |
There was a problem hiding this comment.
This property cannot fail. expected is a copy of before taken before the mutation, and the assertion then compares before against that copy — both derive from the same array, and snapshot() returns a freshly allocated Uint8Array that nothing else holds a reference to. There is no implementation of writeBytes that could make these differ.
Proved it by mutation: replacing the body of snapshot() with return new Uint8Array(0) leaves this test at 1 pass, 0 fail, while the IO-8 tests in byte-queue.test.ts correctly go red. A snapshot that returns nothing at all satisfies this property.
The good news is that IO-8 is genuinely covered — byte-queue.test.ts:128 and :135 check content, non-consumption, and independence in both directions, which is the whole requirement. So this is redundancy rather than a gap, and deleting it costs nothing.
If you would rather keep a property here, the one worth having is the half that unit tests state only by example: for arbitrary chunk sequences, snapshot() equals the concatenation of everything written and leaves size unchanged. That would have caught the mutant.
| 'ByteQueue write-then-read round trip, 64 KiB (warm-JIT, not end-to-end)', | ||
| () => { | ||
| const source = new ByteQueue(); | ||
| source.writeBytes(LARGE); |
There was a problem hiding this comment.
The measured region includes its own setup, which blunts the regression floor this is meant to give Phases 6 and 8.
This block and the snapshot block below both do a 64 KiB writeBytes(LARGE) inside the timed closure — and writeBytes copies (bytes.slice()), so that is a full 64 KiB allocation and copy attributed to whatever the bench is named after. For the round-trip bench the copy is comparable in cost to the read under test; for the snapshot bench it is doing the same work twice, once as setup and once as the measurement.
The effect is roughly halved sensitivity: a 2x regression in read or snapshot would show up as something closer to 1.5x, which is exactly the margin a regression floor needs to catch. mitata's bench(name, fn) has no per-iteration setup hook, so the usual fix is to hoist a prepared queue into the closure and measure only the operation — accepting that read consumes it, so the round-trip bench needs a small pool of pre-filled queues or a writeBytes of a pre-sliced chunk that is cheap relative to the read.
Not urgent — the comment is already honest that this is a warm-JIT baseline and not end-to-end. But the first block above measures writeBytes cleanly, and it would be good if all three were equally trustworthy before another phase starts diffing against them.
Implement product-spec §5 byte-streaming primitives in a new packages/core/src/io/,
per docs/superpowers/specs/2026-07-24-phase3a-io-contracts-design.md:
snapshot/copyTo non-consuming and an AllocationLimitError ceiling (IO-9).
to min(live cursors). Deliberately uncapped — every cap the spec mandates sits in
§6 and belongs to Phase 3b.
skip, charset decode/encode, flush vs emit, Web Streams bridges (IO-1–IO-24, IO-16).
untruncated payload (IO-25–IO-29).
and IO-39 are not built — no registry exists, same simplification as SEAM-5–SEAM-10.
Nothing enters the public barrel. Every export is @internal behind src/io/index.ts,
and packages/core/etc/core.api.md is byte-identical, so Phase 3b can decide whether
BODY-1's write-to-sink names BufferedSink or the platform's WritableStream.
Teardown is close() only: Symbol.asyncDispose postdates the engines.node >=18.17
floor and TypeScript does not polyfill it for a declaring library. No AbortSignal
and no timer anywhere in src/io/ — IO-40 assigns deadlines to the transport that
owns the socket.