Body lifecycle: empty chunks, multipart boundary quoting, logging tap after close (#77) - #91
Merged
Conversation
`StreamBody.#writeExactly` read, checked the over-run, then wrote — with no
guard on `value.length === 0`. Two consequences, neither diagnosable anywhere
else:
- A source that only ever yields empty chunks never signals `done`, so the
delivered-of-declared check at the end of the loop is never reached. The
audit's probe forwarded 200,000 empty chunks before the source closed.
- Every empty chunk reached the transport sink. To an HTTP/1.1
chunked-encoding transport a zero-length chunk is the TERMINATING chunk —
`io/buffered-sink.ts:66-71` says so and refuses to emit one — so tolerating
it ends the request body early while the copy still believes it is mid-body.
`assertNonEmptyChunk` now raises `SourceContractViolationError` with
`io/retention-window.ts:177-183`'s wording and error type, which is also what
`body/response-body-logging.ts:82-88` does for BODY-25 on the response side. A
request body reaches both this copy and `BufferedSource`; a divergence would
make the same upstream fail or succeed depending only on which wrapper it
passed through.
A declared length of 0 stays a legitimate empty write (BODY-10): that is a
source signalling `{done: true}` immediately, which never reaches the check.
The `pipeTo` path (unknown declared length) is unchanged — the runtime owns
that pump, and HTTP-39/BODY-10's rule is scoped to the exact-length copy.
`bun run api:local` on core: no change, the report records signatures only.
Refs #77, #67. HTTP-39, BODY-10.
`MultipartBody` built its own `Content-Type` by interpolation —
`multipart/form-data; boundary=${boundary}` — while `BOUNDARY_PATTERN`
(`multipart-body.ts:29-31`) admits the full RFC 2046 `bchars` set. `bchars`
and RFC 9110 `tchar` are different: ' ', ',', ':', '=', '?', '/', '(' and ')'
are legal in a boundary and illegal bare in a header parameter value. So
`multipartBody(parts, 'a,b')` emitted `boundary=a,b`, which an RFC 9110
parameter parser reads as `boundary=a` plus a junk parameter and then never
finds a delimiter for. Node's own FormData parser rejects the whole body with
`TypeError: Failed to parse body as FormData`.
The header is now rendered through `MediaType.of(...).render()`, the module
that already owns HTTP-25's token-or-quoted-string decision and guarantees
`parse(render(x)) === x`. A boundary that IS a bare token still renders bare,
so the generated default and every existing expectation are byte-identical.
Narrowing `validateBoundary` to `tchar` was rejected (D15): HTTP-51 asks that
a boundary violating the RFC 2046 grammar be refused, not that a conforming
one be. The defect was in the rendering.
The round-trip case is a parser disagreement, not a one-runtime bug: Bun's
`Response.formData()` tolerates the unquoted form, Node's does not. The Bun
rows are the regression guard; the reproducer is the matching case in
`tests/node-conformance/body-lifecycle.test.mjs` (next commit).
Refs #77, #67. HTTP-51.
…close()
`closeDelegate` releases the reader (`response-body-logging.ts:62`), but
`snapshot()` called `startDrain` unconditionally. So a `snapshot()` after
`close()` read from a detached reader, `drainOnce`'s catch cached the raw
`TypeError: Invalid state: The reader is not attached to a stream` as this
wrapper's `failure`, and `error()` then reported a fabricated upstream failure
forever — over a capture that never failed. `read()` after `close()` rejected
with the same raw TypeError instead of an error from the SDK's own tree.
`startDrain` now returns immediately when the wrapper is closed and no drain
was ever started; a drain already in flight is left alone, because on the
fits-cap path the drain closes the delegate itself and its promise is what
`read()` awaits. `read()` gains a `ClosedResourceError('LoggedResponseBody')`
after the fits-cap and tail-consumed checks, in that order:
- fits-cap still serves a fresh non-consuming view after close — BODY-23 and
BODY-28 both require it, so "closed" must not mean "unreadable";
- a consumed tail still reports `ConsumedBodyError`, which says more than
"closed" (BODY-24);
- everything else with the delegate gone is IO-42's state error, because the
captured prefix is not the whole body and serving it would hand the consumer
a silently truncated response.
`error()` is unchanged and now cannot be poisoned. `snapshot()` is the
post-mortem accessor BODY-28 asks for.
`bun run api:local` on core: no change — `LoggedResponseBody` is `@internal`.
Refs #77, #67. BODY-26, BODY-27, BODY-28, IO-42.
…he three #77 fixes on Node `toReadableStream`, `toWritableStream`, `TeeSink`'s bridge, `withRequestLogging` and `withResponseLogging` had zero cases under `tests/node-conformance/`. Every one is a hand-written underlying source or sink object, so what they exercise is the runtime's own pull scheduling, cancel dispatch and reader-lock bookkeeping — CLAUDE.md's runtime-divergent surface, and the tree's membership rule. `io-byte-stream.test.mjs` (9 cases): one-chunk-at-a-time pull rather than an eager drain; natural EOF closing the bridge but not the owning source, so an outstanding peek is still readable (IO-19); cancel closing the source AND releasing the caller stream's lock; a mid-stream read failure closing the source itself, because the spec does not dispatch `cancel` on an errored stream; `pipeTo` chaining close through the bridge to the destination; abort carrying its reason instead of collapsing into a graceful close; a zero-length chunk dropped rather than forwarded as a chunked-encoding terminator; the tee bridge routing through the tee so the tap still sees the bytes; abort reaching the primary while the tap survives. `body-lifecycle.test.mjs` (13 cases): the multipart boundary round-trip; the zero-length-chunk refusal and the declared-length-0 allowance; the request tap's mirror, its clear-between-writes and its abort of a sink the delegate never touched; the response tap's prefix-then-tail, its close-once cancel, its inertness after `close()` and its repeatable fits-cap reads. The multipart rows are the reason this is not optional. Bun's `Response.formData()` accepts `boundary=a,b`; Node's rejects the whole body with `TypeError: Failed to parse body as FormData`. The Bun suite was green over a Content-Type no Node peer could parse, and only a case here reproduces it. Both taps are `@internal`, so they come in by direct `dist/` path like `io/`. Refs #77, #67. IO-16, IO-19, IO-26, HTTP-39, BODY-10, HTTP-51, BODY-17..28.
…ayBuffer `BodyInit` excludes a view over a `SharedArrayBuffer`, so `Promise<Uint8Array>` — whose default parameter is `ArrayBufferLike` — is not assignable to the platform `Response` constructor the boundary round-trip feeds. Bun's runner does not typecheck, so this only surfaced under `bun run typecheck`. Refs #77, #67.
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #77. Part of the audit remediation umbrella #67, milestone 4, wave 4. Decisions: D15 (mine), D0 and D1 (constraining).
What changed
1.
streamBodyrefuses a zero-length delivery during an exact-length copy (HIGH).StreamBody.#writeExactlyread, checked the over-run, then wrote, with no guard onvalue.length === 0. Two consequences, neither diagnosable anywhere else: a source that only ever yields empty chunks never signalsdone, so the delivered-of-declared check at the end of the loop is never reached (the auditor's probe forwarded 200,000 empty chunks first); and every empty chunk reached the transport sink, where to an HTTP/1.1 chunked-encoding transport a zero-length chunk is the terminating chunk —io/buffered-sink.ts:66-71says so and refuses to emit one.assertNonEmptyChunknow raisesSourceContractViolationError, withio/retention-window.ts:177-183's wording and error type, which is also whatbody/response-body-logging.ts:82-88already does for BODY-25 on the response side. A declared length of 0 stays a legitimate empty write: that is a source signalling{done: true}immediately, which never reaches the check. ThepipeTopath (unknown declared length) is unchanged — the runtime owns that pump and HTTP-39/BODY-10 scopes its rule to the exact-length copy.2. The multipart boundary parameter is quoted when it is not a bare token.
MultipartBodybuiltmultipart/form-data; boundary=${boundary}by interpolation whileBOUNDARY_PATTERN(multipart-body.ts:29-31) admits the full RFC 2046bcharsset — andbcharsand RFC 9110tcharare different:' ',,,:,=,?,/,(and)are legal in a boundary and illegal bare in a header parameter value. The header is now rendered throughMediaType.of(...).render(), the module that already owns HTTP-25's token-or-quoted-string decision and guaranteesparse(render(x)) === x. A boundary that is a bare token still renders bare, so the generated default and every prior expectation are byte-identical. NarrowingvalidateBoundarytotcharwas rejected per D15: HTTP-51 asks that a boundary violating RFC 2046 be refused, not that a conforming one be — the defect was in the rendering.3. The response logging tap is inert after
close(), not poisoned by it.closeDelegatereleases the reader (response-body-logging.ts:62) butsnapshot()calledstartDrainunconditionally, so a post-close snapshot read from a detached reader,drainOnce's catch cached the rawTypeError: Invalid state: The reader is not attached to a streamas the wrapper'sfailure, anderror()reported that fabricated upstream failure forever over a capture that never failed.read()afterclose()rejected with the same rawTypeError.startDrainnow returns immediately when the wrapper is closed and no drain was ever started; a drain already in flight is left alone, because on the fits-cap path the drain closes the delegate itself and its promise is whatread()awaits.read()gainsClosedResourceError('LoggedResponseBody')after the fits-cap and tail-consumed checks, in that order — fits-cap still serves a fresh non-consuming view after close (BODY-23 and BODY-28 both require it, so "closed" must not mean "unreadable"), a consumed tail still reportsConsumedBodyErrorbecause that says more than "closed" (BODY-24), and everything else with the delegate gone is IO-42's state error rather than a silently truncated response.4. The five Web Streams bridges get Node-runtime coverage, which they had none of.
toReadableStream,toWritableStream,TeeSink's bridge,withRequestLoggingandwithResponseLoggingare each a hand-written underlying source or sink object, so what they exercise is the runtime's own pull scheduling, cancel dispatch and reader-lock bookkeeping — CLAUDE.md's runtime-divergent surface. Cases went into the two existing topic files, per D15, not one file per bridge.Test rows added
packages/core/src/body/stream-body.test.tspackages/core/src/body/multipart-body.test.tsResponse.formData()round-trips overa,b,bound ary,a:b,a=b,a?b,(a)/bpackages/core/src/body/response-body-logging.test.tsClosedResourceError; close-then-read in the exceeds-cap regime; close-then-error reports only the genuine failure; fits-cap stays repeatably readable after its own closetests/node-conformance/io-byte-stream.test.mjscancelon an errored stream;pipeTochaining close to the destination; abort carrying its reason instead of collapsing into a close; a zero-length chunk dropped rather than forwarded; the tee bridge routing through the tee so the tap still sees the bytes; abort reaching the primary while the tap survivestests/node-conformance/body-lifecycle.test.mjsclose(), and repeatable fits-cap readstests/node-conformance/README.md's membership rule now names the five bridges and the two files they landed in.The multipart rows are the reason the Node tree is not optional here. Bun's
Response.formData()acceptsboundary=a,b; Node's (undici's) rejects the whole body withTypeError: Failed to parse body as FormData. The Bun suite was green over aContent-Typeno Node peer could parse. The Bun rows are the regression guard; only the Node case reproduces the defect. Both test files say so where the cases are.Deviation rows added
None, and one judgement call to flag.
docs/deviations.mdis outside this task's file partition (it belongs to #73 this wave), so no row was written; this is the recommendation for the supervisor to accept or reject on the umbrella.HTTP-39/BODY-10 scopes its rule to "a zero-length read for a positive request". The guard here fires on any zero-length delivery inside the exact-length copy, including when the declared length is 0 — a widening. It is the only reading that also satisfies the same sentence's "never as an infinite spin": with the guard scoped to
declared > 0, an unbounded source of empty chunks against a declared length of 0 still spins forever. Two sibling modules already resolved the identical "for a positive requested count" qualifier the same way and recorded it in a source comment rather than a deviation row —io/retention-window.ts:177-183(IO-17) andbody/response-body-logging.ts:70-88(BODY-25) — so this change follows that precedent and cites both. If the supervisor prefers a row, the evidence ispackages/core/src/body/stream-body.ts:11-39.No other reading here departs from the spec text: quoting the boundary is what makes HTTP-51 and HTTP-25 hold rather than a departure from either, and
ClosedResourceErroron a closed wrapper is IO-42's own rule.Deferred — release machinery
Suspended under D1; nothing below was done.
@dexpace/core, covering three observable behaviour changes:streamBody(...).writeToon a declared-length body now rejects withSourceContractViolationErrorwhere it previously forwarded the empty chunk and reportedEndOfStreamError(or never returned);MultipartBody.mediaTypenow quotes a non-tokenboundaryparameter, so a consumer string-matching on the old unquoted form sees a different value;withResponseLogging'sread()afterclose()now rejects withClosedResourceErrorinstead of a rawTypeError, anderror()no longer reports a close-induced failure. The issue text asks for exactly this changeset; D1 overrides it..d.tsprose changed and is therefore also patch-note material:streamBody's andStreamBody.writeTo's@throwsgained aSourceContractViolationErrorclause, andMultipartBody.mediaType's summary now states the quoting rule.packages/core/etc/core.api.mdis unchanged — the report records signatures, not doc comments, confirmed by runningbun run api:localafter each of the three fixes.docs/first-release.mdedit, though theMultipartBody.mediaTypechange is squarely in its "free only before the first version bump" class.Gates
node .claude/skills/ci-preflight/run-ci.mjs --clean, run once on the final tree, without--node-floor:test:noderan on Node v26.2.0; theengines.nodefloor leg (20.3.0) was not exercised locally, per the run's instruction to skip--node-floorwhile other agents run alongside.node .claude/skills/housekeeping/probe.mjs: no drift found.