Repository navigation
Phase 3b: body lifecycle — the body layer - #50
Merged
Merged
Conversation
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.
This was referenced Sep 16, 2026
Closed
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.
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 plainrequire,securerandominmultipart_body.rb(on phase 0's allowlist); the gemspec keeps zeroadd_dependencylines.Dexpace::Body— the production contract every variant satisfies (#write_to,#each,#source,#close,#replayable?,#content_length—-1, nevernil,BODY-35—#media_type,#to_replayable), its eight factories (.bytes,.stringwith 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 anensure(BODY-30–BODY-33) — the two private copy routines that raise through 3a'sStreamError.short_transfersoBODY-13's message form cannot diverge, andHTTP-46's identity default for#==/#eql?/#hash.BytesBody,BufferBody(materialize-once's product over 3a's FIFO),StreamBody(BODY-9's probe ispos+seek(pos), neverrespond_to?(:rewind)— a pipe answerstrueand then raisesErrno::ESPIPE; a race-safe rewind guard;close: trueforces single-use becauseBODY-8's own text says so; a freshBufferedSource.wrappingper write that it does not close unless it owns the stream),ChunkedBody,FormBody(over the newPercentEncoding.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 perBODY-11item), andMultipartBodywith its nestedPartandBuilder(HTTP-51: aSecureRandomboundary 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 aBufferedSource.wrappingwhose close releases the transport resource (HTTP-41,BODY-14,BODY-15), with#preview(cap:)clamping down toMAX_MATERIALIZED_BYTESand never up (BODY-32).Response#close,#body_string,#body_bytes—#body_stringis the one decode boundary in the SDK (HTTP-42): retag to BINARY, then#encodeto the explicitly named target withinvalid: :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#sourcereturned and then the body, in that order.RequestLoggingBody(a freshTeeSinkper write, soBODY-18holds by construction; the tap bound before the write so a failure mid-stream still leaves the attempted bytes captured,BODY-20) andResponseLoggingBody(BODY-22–BODY-29: one drain under a mutex held across the flag and not the drain;BODY-23fits-cap told fromBODY-24over-cap by reading one more byte that is kept outside the capture and replayed by the tail; every#sourcea fresh non-consuming view; the over-capTailowned through.wrappingso its close reaches the wrapper's one guard; a drain error cached; a close failure after a full capture best-effort).TypedResponseand the_ResponseHandlerRBS interface — the lazy typed wrapper whose two handlers phase 7a supplies: the handler runs exactly once across threads and fibers (HTTP-44,HTTP-45), anilresult is memoised (not@value ||=), a failure is memoised as the same exception object, a handler raising outsideStandardErrorstill settles the latch, and#status/#reason/#headersnever take the parse lock.sig/dexpace/http/{request,response}.rbstypebodyasDexpace::Body?on the reader,.build,.newand#initialize(P3-15, the narrowing phase 1 postponed to phase 3).sig/mirrorslib/one file per file with every private method and ivar declared for the strictcoreSteep target; YARD 100% (369 methods); the surface manifest regenerated once (366 → 515 lines, every added row read against the plan's list, noprivate_constantleaked).tools/measure_view_retention.rbis 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_handlerrescues::Exceptionexplicitly rather than settling viaensure/$!; the multipart subtype is validated as one bare token, so the plan's "not a subtypestdlib leak" finding does not reproduce;RequestLoggingBodyvalidatestap_limit:at construction with the tee's rule; and — from review —Response's readers close the handle they take, the over-capTailforwards a live delegate through#read_into(one fill, never blocking tocount),MultipartBody#==folds the subtype in,ResponseBody.newrequires#peekbeside#read_into, andResponseLoggingBodyasks its delegate for#sourceonce 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
Responseand 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
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.synchronizeblock 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 undertimeout, 200/200.StreamBodyover a realIO.pipe(noErrno::ESPIPEescapes; not rewindable) and over aTempfileseeked to a non-zero origin (replay returns there, not to 0);TypedResponseunder threads, fibers and a non-StandardErrorhandler with a 5 s watchdog; the three-step decode under a hostileEncoding.default_internal; the multipart boundary grammar, quoting and the length property; both logging regimes over a live pipe;buffer_boundedover 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.streamover an already-closed stream leaks a raw::IOError("closed stream") out of theBODY-9probe'sio.pos— the same argument-boundary class asBody.string("caf\xE9".b)'sEncoding::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).MultipartBodyrefuses 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.close_quietlydisposal-route question phase 2 postponed gains its first call site here (BODY-28) and stays with phase 4b, Task 2.