Skip to content

Phase 3b: body lifecycle — the body layer - #50

Merged
Wahbeh-Mohammad merged 5 commits into
mainfrom
12-phase-3b-body-lifecycle
Sep 16, 2026
Merged

Wahbeh-Mohammad merged 5 commits into
mainfrom
12-phase-3b-body-lifecycle

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #12. First of three phase 3b PRs — the code. Its tests are #51 and the phase record is #52.

What lands

The body layer in dexpace-core — 34 files, +2,849 / −30 — everything a payload is between a caller and a socket, and the last piece of the wire model phase 1 carried as an opaque member. Nothing here talks to a socket, and 3b adds one plain require, securerandom in multipart_body.rb (on phase 0's allowlist); the gemspec keeps zero add_dependency lines.

  • Dexpace::Body — the production contract every variant satisfies (#write_to, #each, #source, #close, #replayable?, #content_length — -1, never nil, BODY-35 — #media_type, #to_replayable), its eight factories (.bytes, .string with eager encoding, .file, .stream, .chunked, .form, .multipart, .buffer), MAX_BUFFERED_ERROR_BODY_BYTES (1 MiB), .buffer_bounded — which drains through #source, stops at the cap so the bytes beyond are not read, truncates markerlessly and closes the original in an ensure (BODY-30–BODY-33) — the two private copy routines that raise through 3a's StreamError.short_transfer so BODY-13's message form cannot diverge, and HTTP-46's identity default for #==/#eql?/#hash.
  • Seven request-body variants — BytesBody, BufferBody (materialize-once's product over 3a's FIFO), StreamBody (BODY-9's probe is pos + seek(pos), never respond_to?(:rewind) — a pipe answers true and then raises Errno::ESPIPE; a race-safe rewind guard; close: true forces single-use because BODY-8's own text says so; a fresh BufferedSource.wrapping per write that it does not close unless it owns the stream), ChunkedBody, FormBody (over the new PercentEncoding.encode_form / .encode_form_component, kept beside the RFC 3986 pair so the two cannot be interchanged), FileBody (::IO.copy_stream, a fresh handle per write, no #to_path — BODY-12; one validation clause per BODY-11 item), and MultipartBody with its nested Part and Builder (HTTP-51: a SecureRandom boundary from the alphanumeric subset, the length and the bytes from one routine, lazily memoised without consuming a single-use part; a subtype validated as one bare token, because "form-data;x=1" smuggled a parameter).
  • ResponseBody — the single-use handle over a BufferedSource.wrapping whose close releases the transport resource (HTTP-41, BODY-14, BODY-15), with #preview(cap:) clamping down to MAX_MATERIALIZED_BYTES and never up (BODY-32).
  • Response#close, #body_string, #body_bytes — #body_string is the one decode boundary in the SDK (HTTP-42): retag to BINARY, then #encode to the explicitly named target with invalid: :replace, undef: :replace, UTF-8 when no charset is declared, never BINARY. The one-step recipe design §3.1 states ("café".b.encode(...)) mangles every non-ASCII byte on every interpreter, which is why that sentence is on phase 10's inbound list. Both readers close the handle #source returned and then the body, in that order.
  • The two logging wrappers — RequestLoggingBody (a fresh TeeSink per write, so BODY-18 holds by construction; the tap bound before the write so a failure mid-stream still leaves the attempted bytes captured, BODY-20) and ResponseLoggingBody (BODY-22–BODY-29: one drain under a mutex held across the flag and not the drain; BODY-23 fits-cap told from BODY-24 over-cap by reading one more byte that is kept outside the capture and replayed by the tail; every #source a fresh non-consuming view; the over-cap Tail owned through .wrapping so its close reaches the wrapper's one guard; a drain error cached; a close failure after a full capture best-effort).
  • TypedResponse and the _ResponseHandler RBS interface — the lazy typed wrapper whose two handlers phase 7a supplies: the handler runs exactly once across threads and fibers (HTTP-44, HTTP-45), a nil result is memoised (not @value ||=), a failure is memoised as the same exception object, a handler raising outside StandardError still settles the latch, and #status/#reason/#headers never take the parse lock.
  • The body-member narrowing — sig/dexpace/http/{request,response}.rbs type body as Dexpace::Body? on the reader, .build, .new and #initialize (P3-15, the narrowing phase 1 postponed to phase 3).
  • sig/ mirrors lib/ one file per file with every private method and ivar declared for the strict core Steep target; YARD 100% (369 methods); the surface manifest regenerated once (366 → 515 lines, every added row read against the plan's list, no private_constant leaked). tools/measure_view_retention.rb is Task 13's measurement, re-run on the merged tree rather than copied from the plan (3a's fill fix changed the profile). The core smoke suite and phase 2's non-relative-requires pin are on this branch because the code change invalidates both.

Decisions taken in the open, against the plan's text

Twenty-two are itemised in the checklist's "Deviations from the plan"; the ones a reader should know: Dexpace::Body's identity default for #==/#eql?/#hash (strict Steep refused == on a module-typed value; three manifest rows the plan's list lacked); TypedResponse#run_handler rescues ::Exception explicitly rather than settling via ensure/$!; the multipart subtype is validated as one bare token, so the plan's "not a subtype stdlib leak" finding does not reproduce; RequestLoggingBody validates tap_limit: at construction with the tee's rule; and — from review — Response's readers close the handle they take, the over-cap Tail forwards a live delegate through #read_into (one fill, never blocking to count), MultipartBody#== folds the subtype in, ResponseBody.new requires #peek beside #read_into, and ResponseLoggingBody asks its delegate for #source once and holds the handle.

Layering

Each tip of the stack is green under every gate on its own tree. Unlike phase 3a, this branch is green on the SimpleCov floor too — 84.21% on 4.0.6 and 84.39% on 3.2.11 (784 runs, 0 failures), because the phase-1 suites the code tip still carries reach most of the new layer through Response and the smoke suite. #51 takes the same tree to 99.96% (2,977 / 2,978 lines; the one uncovered line is phase 2's registry race-only branch).

Verification

  • Independent reviewer, two fix rounds: six round-0 findings (4 should_fix, 2 nits) and two round-1 findings (1 should_fix, 1 nit), each verified resolved individually on re-review, then a full third pass — approve with one nit.
  • The mutation battery. The plan's fifty single-edit mutations were re-run by the implementer (55, one added for BODY-32's clamp deletion after round 0 caught it surviving) and sampled independently by each reviewer (27, 37, 38): every one caught at this tip, the interpreter-sensitive ones on 3.2.11 too.
  • Every synchronize block read — StreamBody's consume-once latch, ResponseLoggingBody's drain flag, TypedResponse's parse claim: each a flag flip, never across a write, a drain or a delegate call. The twenty hang-shaped tests looped ten times each on 4.0.6 under timeout, 200/200.
  • By experiment on both interpreters: StreamBody over a real IO.pipe (no Errno::ESPIPE escapes; not rewindable) and over a Tempfile seeked to a non-zero origin (replay returns there, not to 0); TypedResponse under threads, fibers and a non-StandardError handler with a 5 s watchdog; the three-step decode under a hostile Encoding.default_internal; the multipart boundary grammar, quoting and the length property; both logging regimes over a live pipe; buffer_bounded over a 2 MiB body pulling 64 KiB blocks and stopping at 1 MiB; FileBody's descriptor count flat over twenty failing writes.

Known follow-ups from the final review (not blocking)

  • Body.stream over an already-closed stream leaks a raw ::IOError ("closed stream") out of the BODY-9 probe's io.pos — the same argument-boundary class as Body.string("caf\xE9".b)'s Encoding::UndefinedConversionError, which the checklist already routes to phase 1's plan, Task 1; the routing bullet names only the string case (R2-1, on Phase 3b: body lifecycle — documentation and phase record #52).
  • MultipartBody refuses non-ASCII part names and filenames because its part-header sweep is phase 1's outbound header grammar (HTAB and printable ASCII only). Kept as the design decided and documented; routed to phase 10's inbound list.
  • The close_quietly disposal-route question phase 2 postponed gains its first call site here (BODY-28) and stays with phase 4b, Task 2.

Phase 3b (issue #12). One public module, Dexpace::Body, supplies
HTTP-36's whole vocabulary over one hook, #write_to(sink): BODY-1's
false default, BODY-35's -1 sentinel, P3-23's read side (#source
raising by name, #close a no-op), BODY-3's #to_replayable, §10.2's
#each derived from #write_to (P3-21), HTTP-46's identity default, the
eight factories HTTP-38 asks for in one place, BODY-32's cap clamp,
BODY-30/HTTP-52's .buffer_bounded at MAX_BUFFERED_ERROR_BODY_BYTES
draining through #source and closing the original in an ensure, the
two private exact-length copy routines (HTTP-39/BODY-10, BODY-13,
BODY-25; P3-20) and the two latches BODY-6, BODY-7 and BODY-9 share.

The seven request-body variants, flat under Dexpace:: and filed under
lib/dexpace/http/body/ (P3-14): BytesBody and the eager Body.string;
BufferBody, materialize-once's product, reading through a fresh view so
BODY-30's copy stays repeatable; StreamBody with BODY-9's mark/reset
given a Ruby subject -- `pos` then `seek(pos)`, rewinding to the
construction position, never 0 (P3-16) -- and close: true forcing
single-use because BODY-8's own sentence says so; ChunkedBody,
unconditionally single-use with no replayable: keyword (P3-19);
FormBody over the +-for-space encoder that now sits beside the RFC
3986 one in PercentEncoding; FileBody with six fail-fast clauses,
::IO.copy_stream's window and deliberately no #to_path (P3-17);
MultipartBody with one framing routine for the bytes and the length
(count_only:, P3-29), RFC 2046's boundary grammar, HTTP-51's quote
escape and CR/LF rejection, a bare-token subtype, and HTTP-3's
Builder. The response side: ResponseBody, HTTP-41/BODY-14's single-use
handle with Closeable's latch, a block form and BODY-33's preview;
RequestLoggingBody, a fresh TeeSink per write (BODY-17..BODY-21,
BODY-37); ResponseLoggingBody, the drain-once wrapper in two regimes
behind one close-once guard (BODY-22..BODY-29), whose over-cap tail is
BufferedSource.wrapping-owned so its close reaches the wrapper's latch
(R10), with Dexpace.close_quietly's first call site. Every sig/ mirror
declares every method for the strict core Steep target.
Response gains HTTP-43's #close -- a pure forward, because a Data
instance holds no latch and the requirement delegates idempotence to
the body's -- HTTP-42's #body_string, the SDK's one decode boundary in
three load-bearing steps (resolve the charset through MediaType#charset,
RETAG the BINARY bytes through 3a's #read_string, transcode with the
target NAMED, because a target-less #encode follows the host's
Encoding.default_internal and the retag-less recipe design §3.1 states
replaces every non-ASCII byte), and #body_bytes, which decodes nothing;
both close the body in an ensure (BODY-16). TypedResponse is HTTP-44's
lazy wrapper: five raw accessors forwarded without a lock, #value
memoised through an explicit state machine so a nil or false success
runs the handler once and a failure is re-raised as the same object,
HTTP-45's serialization a mutex across the state flip only, and a
handler raising outside StandardError still settling the state; the
handler is any #call(response), the RBS interface _ResponseHandler,
which phase 7 supplies into (R7). sig/ narrows Request#body and
Response#body from untyped to Dexpace::Body? on the reader, .build,
.new and #initialize (P3-15) -- the narrowing phase 1 postponed to
phase 3, free because no release tag exists to diff against. The entry
file requires the twelve files in dependency order.
The runtime surface manifest of dexpace-core grows from 366 to 515
lines through `rake surface:regenerate`, once, and every added row was
read against the plan's list: fourteen constants (R6's twelve plus
MultipartBody::Part and ::Builder), MAX_BUFFERED_ERROR_BODY_BYTES,
FormBody::MEDIA_TYPE, the four framing constants, Response's three
readers with .resolve_charset, the two form-encoder functions,
ResponseBody.new's block-form override, and Body's equality default;
nothing removed and no private_constant present. The core smoke suite's
constant list gains the body layer and pre-requires securerandom beside
uri and strscan, since its top-level snapshot would otherwise attribute
::SecureRandom to the entry file; phase 2's seam_surface_test pin on
core's non-relative requires names securerandom beside strscan and uri,
the one require phase 3b adds, on the allowlist. Both are files a gate
reads at run time, so they travel with the code.
tools/measure_view_retention.rb is Task 13's measurement on the BODY-23
drain: it prints numbers, asserts nothing, needs no bundled gem, and its
numbers are in the checklist beside the plan's decision 5.
Review round 0 of phase 3b found four defects in the body layer and two
nits, none a red gate. This is the code half of the repair.

R0-1: Response#body_string, #body_bytes and Body.buffer_bounded took the
handle #source returns and never closed it. For a ResponseBody that is
the same stream close the body's own performs, but for a BufferBody and
a fits-cap ResponseLoggingBody the handle is a fresh #peek view of a
buffer that outlives the call, and neither body's #close reaches it, so
each read left one registered view behind -- the Array#delete growth
Task 13 measured, against the design's own "every view core takes,
core closes". The two readers now share a private #read_through that
closes the handle in its own ensure and then the body in the method's;
copy_bounded closes the source it took the same way, guarded with
respond_to? as the bodies' #release methods are, because
Dexpace::IO::_Source declares no #close. The over-cap composite's close
is the Tail's, which deregisters its prefix view too.

R0-3: the over-cap Tail forwarded the live delegate as
BufferedSource#read(count), IO-16's bulk read, which fills until count
bytes or EOF, so on an open connection the composite delivered the rest
of the body in .wrapping's 64 KiB segments or at EOF where the delegate
alone returns what has arrived. It now forwards through #read_into,
IO-1's one-fill primitive -- the only member the regime probe asks of
the delegate's source (plan decision 9), so the delegate's source
contract stays _Source's single method -- with a zero return raising
BODY-25's violation through 3a's helper. The forwarding sits in a
private #read_tail beside #take_pending for Metrics/AbcSize.

R0-4: MultipartBody#== and #hash ignored the subtype, so two bodies
framing the same parts as multipart/form-data and multipart/mixed
compared equal under HTTP-46 while their media types differed. Both now
fold the subtype in.

R0-5: ResponseBody admitted a bare #read_into source and then used
#peek, #each, #read and #read_string on it. The construction check now
asks for #read_into and #peek, the narrowest member only a
BufferedSource-shaped reader answers, with a message naming both.

R0-6: the entry file's twelve require_relatives follow the plan's Task
14 Step 1 order again (file_body after buffer_body); both orders were
dependency-safe.

sig/ declares the two private methods for the strict core target. The
runtime surface manifest is unchanged: nothing public was added.
ResponseLoggingBody took @delegate.source three times -- for the prefix
fill, for the regime probe and for the over-cap tail -- which is correct
only over BODY-14's same-handle ResponseBody. The wrapper's stated
delegate contract is #source, #content_length, #media_type and #close,
and BufferBody, inside that contract, answers #source with a fresh
non-consuming #peek view per call by design (P3-23). Over it the
over-cap composite replayed the prefix twice and then the whole body
(15 bytes for a 10-byte body, also through Response#body_bytes), and the
fits-cap regime left the fill view registered in the delegate's buffer
against the design's "every view core takes, core closes" (review round
1, R1-1).

The drain now asks once and holds the handle in @upstream; fill_prefix,
capture_complete? and the Tail read from that one handle, and #release
closes it ahead of the delegate, the delegate's close in an ensure so
BODY-15's transport release is attempted whatever the handle's close
did. One respond_to?(:close) guard covers both handles that cannot be
closed -- a Dexpace::IO::_Source declares no #close, and before a drain
has asked there is no handle at all. The RBS mirror gains the ivar and
the two narrowed private signatures.
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* labels Sep 16, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 8fd2413 into main Sep 16, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 12-phase-3b-body-lifecycle branch September 16, 2026 12:19
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant