Repository navigation
Phase 8a: synchronous transport — the Net::HTTP adapter and the conformance gem - #90
Merged
Wahbeh-Mohammad merged 6 commits intoSep 21, 2026
Conversation
…se 8a) dexpace-transport-net_http: Dexpace::Transport::NetHTTP with .build over a fresh-per-call Net::HTTP and .using over a caller's own, the Adapter, the per-response ResponsePump with its head-adaptation handshake, RequestMapper and ResponseMapper, Deadline, Failures, TLSSettings, ProxyRoute, the require-time registration under :net_http, and the gemspec's net-http >= 0.4. dexpace-conformance: the assertion protocol phase 0 postponed (Failure, Vacuous, Assertion, Result, Report), the twenty-eight-assertion TransportSuite, TransportCase with its SettleOnly guard, BorrowedPair, the WireServer fixture and its Scripts, MinitestDriver, the opt-in RSpecDriver, and the RecordingSpan and Allocations doubles. dexpace-core: Dexpace::TransportError < ::IOError, Configuration::Keys::REQUEST_TIMEOUT and Instrumentation::Events::TRANSPORT_HEADER_DROPPED, with the pins the two constants moved. Repository: tempfile on the require allowlist with its fixture, BUNDLE_PATH scoped in the clean-bundle gate, socket and tempfile on the conformance Steep target, the surface manifests regenerated once, the net-http Timeout-thread warm-up both adapter gems' test helpers require, and the bare-require helper core's repaired seam-surface test needs.
Phase 8a, review round 0's three code-branch nits. R0-6: the adapter's
ResponseMapper::LENGTH and the conformance fixture's RequestReader length
check were regexp literals a wire value reaches; both are now
::Regexp.new('\A[0-9]{1,15}\z', timeout: 1.0).freeze, core's spelling for
every such pattern (PacingParsers, P6-61), so no header hands #to_i an
unbounded digit run -- a sixteen-digit Content-Length is the unknown-length
sentinel like any other value the grammar refuses. R0-7: the rewritten swap
pin in core's transport_test.rb compared a Proc with an Array and could not
fail; it now snapshots Transport.registered_keys before the swap and asserts
the same list afterwards. R0-10: Allocations.delta re-enabled the collector
unconditionally in its ensure; it now records what GC.disable answered and
re-enables only when it found the collector running, so a host that had GC
off around the call finds it off afterwards. The request_reader sig gains
the private LENGTH declaration the Steep target needs.
Cancellation::Source runs an already-cancelled hook inline, so the
pump's own on_cancel { close } could run #release from inside the
constructor before @subscription was assigned, and #release then
raised NoMethodError on nil.detach after joining a producer that had
already put the request on the wire (review round 1, R1-2). On the
borrowing construction that raise reached Adapter#dispatch's rescue,
which pushed the permit a second time onto a full SizedQueue.
ResponsePump#initialize now asks the token first: a cancelled token
gets a closed pump with no thread started, no socket opened and the
borrowed permit handed straight back, exactly once. Otherwise the
thread is started and the subscription registered after it, as before,
with both slots initialised to nil so the inline #release a cancel
racing the registration triggers finds them; #release joins and
detaches nil-safely, and #produce reads the latch before it exchanges,
so a pump closed before its producer was scheduled never touches the
socket. The RBS types both ivars optional and declares the new private
start_producer. Recorded as P8-64.
Net::HTTP.new's default p_addr is :ENV, which consults URI#find_proxy on #start, and uri's find_proxy warns "The environment variable HTTP_PROXY is discouraged" whenever HTTP_PROXY is set and the lower-case http_proxy is not -- before its loopback exemption and before NO_PROXY is read (uri 0.12.5 through 1.1.1, every row). DexpaceTestCase makes every warning fatal, so on such a host test/support/net_http_warmup.rb, required by both adapter gems' test_helper, aborted every suite of both gems and rake test:gems at load (review round 2's R2-1, measured on 4.0.6 under HTTP_PROXY=http://127.0.0.1:9 with no http_proxy). Build the warm-up client the way the adapter builds its own through ProxyRoute: an explicit nil for the four proxy positionals. The comment records the mechanism and the rule the tests branch applies to every raw Net::HTTP a suite starts, so the suites are hermetic under HTTP_PROXY, http_proxy, HTTPS_PROXY and NO_PROXY alike; the two R17 controls that reach :ENV on purpose set http_proxy themselves.
ResponseMapper's YARD already says what an HTTP/1.0 head does and whose gap that is. Say the same for the status: phase 1's Status guards 100-599 (HTTP-10's reading), Net::HTTP parses any three digits and delivers a 999 or a 600 as an HTTPUnknownResponse, and such a head raises Dexpace::InvalidArgumentError after the head with the connection released (measured 2026-09-20; review round 2's R2-2). Whether TRANSPORT-24's "any code" reaches 600-999 is a phase-1 model question, routed to phase 10's inbound list on the docs branch. A comment only: no behaviour, gate or surface changes.
Review round 3's R3-1. ResponseMapper#parse_length! took any single,
grammar-matching Content-Length as the body's length, but net-http reads
a response carrying Transfer-Encoding: chunked under the chunked framing
whatever else the head says -- read_body_0 asks #chunked? before
#content_length on 0.4.1, 0.6.0 and 0.9.1 alike -- so the value was RFC
9112 section 6.3's overridden one and never the number of bytes the pump
would deliver. Every reader that copies exactly `content_length` bytes
(ResponseBody#each, #write_to, #to_replayable) therefore truncated a
chunked body silently when the header was short ("0123456789" delivered
as "01234" under Content-Length: 5) or raised StreamError.short_transfer
when it was long, while #body_string, which drains to end of stream,
read all of it. TRANSPORT-27 maps an invalid Content-Length to the
unknown-length sentinel, and a Content-Length the framing overrides is
the invalid case R4's grammar could not see.
The guard gains `!native.chunked?` -- Net::HTTPHeader#chunked?, the
predicate read_body itself consults -- so such a header is the -1
sentinel like any other value the mapper refuses: the pump reads to end
of stream and every reader delivers the whole body. The native delete in
that branch changes nothing read_body consults under a chunked encoding
and is kept for the one-branch shape. The wire value still reaches the
caller's Dexpace::Headers, as before. R4's comment states the case.
Measured through the real adapter against WireServer on 4.0.6: both
directions now answer -1 and deliver ten bytes through #each, #write_to
and #to_replayable.
This was referenced Sep 21, 2026
Wahbeh-Mohammad
deleted the
30-phase-8a-synchronous-transport-and-conformance
branch
September 21, 2026 07:30
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 #30. First PR of phase 8a's three-PR stack — the code — built off
mainatc53638b(phases 0–6) concurrently with 7a #26, 7b #27 and 7c #28, reviewed and approved there, then rebased and re-proven ontomainat734e6b3(the whole of phase 7) by a reconcile pass before this push. Its tests are the next PR up and the phase record the one after. The first thing in this repository that talks to a socket.What lands
The synchronous
Net::HTTPtransport adapter, the conformance gem, and the phase-levelDexpace::TransportError— 89 files, +4,853 / −46 on top of phase 7'smain—TRANSPORT-1–6,10,11,14–20,22,24–30(twenty-two ✅,TRANSPORT-28's zero-copy clause ⏳ underdocs/first-release.md;TRANSPORT-12/13are 8c's).dexpace-transport-net_httpbecomes a real gem — the gemspec'snet-http >= 0.4(a default gem declared as theNFR-2third-party half; newest under Bundler by the manager's decision, so CI runs 0.9.1 on every row while--localkeeps each interpreter's default: the adapter is proven on 0.4.1, 0.6.0 and 0.9.1 andmatrix_facts_test.rbprints the activeNet::HTTP::VERSIONper row).Dexpace::Transport::NetHTTPwith.default(fresh per call) and theAdapter(.build(tls:, timeout:, …),.owning/.borrowing), theResponsePump(one producer thread per response;#head_or_raisetakes a block that yields the native head on the caller's thread and resumes the producer only when it returns, P8-51; the cancellation subscription lives on the pump for the life of the response and is detached in#release, P8-52; a pump built over an already-cancelled token closes without a producer — round 1's R1-2; the borrowed permit is a one-slotSizedQueue, never aMutex, returned by the producer's ownensure, and the producer never breaks out ofread_bodyso a half-read keep-alive socket is closed rather than left to poison the next response, P8-53; a closed-queue pop is classified through the token —CancelledError,ClosedError, or a retryableTransportErrorbefore any head, P8-54), theRequestMapper(HeaderSyntax.validate_name!/.validate_outbound_value!run again immediately before dispatch —HTTP-17,HTTP-18,XCUT-18), theResponseMapper(TRANSPORT-14drops a malformed inbound header by name underEvents::TRANSPORT_HEADER_DROPPED; an unparseableContent-Lengthis deleted inside the head block beforeread_bodycan raiseNet::HTTPHeaderSyntaxErroron the producer; aContent-LengthbesideTransfer-Encoding: chunkedis the-1sentinel — round 3's R3-1, a real defect:#each/#write_to/#to_replayablehad truncated the chunked body to the bogus length whilebody_stringread all of it; bounded fifteen-digitRegexp.new(…, timeout:)length grammars),Deadline(open/read/write_timeoutfromRequestOptions,Keys::REQUEST_TIMEOUTthe ninth key; theMIN_TIMEOUT_SECONDSclamp),Failures(every non-SDK failure wrapped inDexpace::TransportErroranswering#retryable?with a#phase— the blind spot 6a's P6-4 named is closed for this adapter, anddocs/first-release.md's blocker is ticked),ProxyRoute(the first consumer of 5a'sProxy.resolve;PROXY_LIMITATION_EVENTfor the 401 clauseTRANSPORT-30declines) andTlsSettings(thetls:keyword, plain values typeduntypedforNFR-11).dexpace-conformancebecomes a real gem — the assertion protocol phase 0 postponed:Failure,Vacuous,Assertion,Result(STATUSESfive),Report(aPREAMBLEnaming the two omissions below in every report), theTCPServerWireServeron127.0.0.1:0withRecordedRequestand a privateRequestReader(await_closed_connection(count = 1, timeout:)bounded, P8-56; the per-handler join underJOIN_DEADLINE_SECONDS),Scripts(fifteen functions;.fixed/.largetakehold:),TransportCase/TransportSuite(run(build:, borrow:, waive:, around:, settle:, wire:, assertions:), every assertion in a fresh case torn down inensure),RecordingSpanover 5c's_Span,Allocations.delta(theATTEMPTSagreement loop; GC re-enabled only if it was enabled), and the two thin drivers —MinitestDriver(.drive/.build_case) andRSpecDriver(never required by the entry file;::RSpecresolved at call time). Twenty-eight assertions in five groups: outbound (7), inbound (4), streaming (3), retry/cancellation/failure (8 —TRANSPORT-18vacuous by measurement, reported as a Minitest skip by design §9.3), lifecycle/concurrency/cross-phase (6, incl. 7c'sPAGE-36per-call-options test and 5c'sOBS-21/OBS-25obligations). NoTRANSPORT-12/13row: the antecedent is measured absent on net-http (P8-55).Dexpace::TransportError < ::IOError(include Dexpace::Error,#retryable?,#phase) — the phase-8 charter's phase-level task, landed by the first lane;Configuration::Keys::REQUEST_TIMEOUT;Instrumentation::Events::TRANSPORT_HEADER_DROPPED, the tenth event; their pins moved on this branch.tempfilejoinsRequireAllowlist::ALLOWED(one reviewed line namingTRANSPORT-28, with its positive-control fixture; thesocketdenial's per-gem scope was already phase 0's).clean_bundle_checkscopes the scratch bundle'sBUNDLE_PATHunder the gate's scratch dir, so a declared third-party gem never lands in an interpreter's gem directory again — closing the phase-10 bullet 7a filed. TheSteepfile's:conformancetarget gainslibrary "socket", "tempfile". Every rawNet::HTTPthe suites build passes an explicitnilproxy, never:ENV(round 2's R2-1 — an uppercaseHTTP_PROXYon the host had aborted both gem suites at load);test/support/net_http_warmup.rbparks Ruby's process-wideTimeoutthread at both adapter gems' test-helper load, because net-http below 0.7 opens its connection underTimeout.timeout(P8-62). Core's registry pins that an adapter's require-time registration invalidates in the onetest:gemsprocess run in a bare child (test/support/bare_require.rb,transport_bare_require_test.rb).sig/mirrorslib/one file per file across all three gems (the fifteenprivate_constants withhooks.rbs's comment); the manifests regenerated once — core 1,330 → 1,335 (+5),dexpace-transport-net_http2 → 17,dexpace-conformance2 → 100, the other three unchanged — every row read against the object model;Adapter.owning,Adapter.borrowingandMinitestDriver.build_caseare public@api privaterows (P8-59).Decisions taken in the open, against the plan's text
Ledger rows P8-51–P8-65 in the design's As-built addendum and the checklist's "Deviations from the plan" (forty-three items). Beyond those above: two plan tasks were already built by phase 0 (
clean_bundle_checktookcore_path:; the per-gemsocketdenial); the version-bound net-http facts (0.9.1 stamps no defaultContent-Typeand connects throughTCPSocket.open(open_timeout:), notTimeout.timeout) are P8-61/P8-62;Transport.async_overover a real socket is 8b's; the adapter's sink double isNetHTTPRecordingSink, because every top-level test-support constant must be unique across the six gems' suites in one process.Reconciliation onto phase 7's
mainThe rebase met three code conflicts, all resolved inside the commits that caused them:
lib/dexpace.rbkeeps 7b's, 7c's and 7a's blocks then 8a's;dexpace_test.rbkeeps every layer pin and the mergedPhaseSevenLayersclass; andseam_surface_test.rbis resolved tomain's version exactly — 7a and 8a had converted the same two registry pins to child-process assertions in different shapes, so 7a's merged and reviewed file stands, 8a'sBareRequiremodule andtransport_bare_require_test.rbstay for 8a's own suites, and the unification of the two idioms is a dated phase-10 inbound bullet. The core manifest auto-merged to exactly the regenerated 1,335 rows (+5), theSteepfileandtasks/gates.rakeauto-merged to exactly 8a's hunks beside 7a's:serde_jsonblock and 7b'sgates:serde_boundarytask, anddocs/first-release.mdto exactly 8a's seven hunks. Task 19c's codec half is un-guarded (705d864, tests branch): the deadskip … unless defined?(Dexpace::Serde::JSON::Codec)is gone and the test runs against 7a's real codec — two test-only repairs by the minimum (require "dexpace/serde/json"; the as-builtProtocolErrorcarries#response, not the 4b design's#headers/#body) — so the tests tip's skip count is exactly one. 8a'sclean_bundle_checkBUNDLE_PATHscoping closes the phase-10 bullet 7a filed on 2026-09-20 (dated bracket appended), and the reconcile pass paid 7a's owedgems/dexpace-core/README.mdserialization paragraph. Every file only 8a touched is byte-identical to the reviewed tip; every file onlymaintouched is byte-identical tomain.Layering
Each tip is green under every gate on its own tree — eighteen gates, 7b's included. This branch is green on the SimpleCov floor too — 93.02% on 4.0.6 (0 failures); the tests PR takes the same tree to 99.88% with 3,698 runs / 72,656 assertions and 1 skip —
TRANSPORT-18's measured vacuity, reported by design.Verification
Content-Lengthregexps and theAllocationsGC state (round 1), the pump built over a cancelled token (round 2), the warm-up client's explicitnilproxy (round 3), the chunked-length sentinel (round 4); every other finding was a test or docs gap, each closed by a mutation-proven case.outbufviaclear+<<).WireServer: the chunked-with-Content-Lengthbody whole through every reader with the guard reverted and restored; a cancel from another thread after the head flushed (CancelledError, never a 200); a DNS failure as a retryableTransportErrorwithphase: :connect; a 4 MiB round trip byte-identical to a rawNet::HTTPfetch; a fullTransportSuite.runthrough the real adapter (27 passed, 0 failed, 1 vacuous) leaving one thread; the registry before and after the entry file; both as-built pages' blocks executed.Known follow-ups from the final review (not blocking a gate)
TRANSPORT-18's measured vacuity is reported as a Minitest skip byMinitestDriverper design §9.3 (one skip; the 19c codec half's guard is gone with the rebase).Timeout.timeoutstarting a process-wide thread; rbs 4.2.0'sTCPServer#initializesignature (the untyped local inWireServer); 6b'sREDIR-23ten-second wall-clock bound failing under machine load; 6a'sRETRY-42/RECOV-28eight-thread test erroring in 2 of 5 whole-process 3.2.11 runs; whether phase 1'sStatusshould admit000–999(a999head is anInvalidArgumentErrortoday, with the connection released); the two child-process idioms for one registry property (7a's private helper and 8a'sBareRequire).docs/first-release.md:TRANSPORT-28's zero-copy clause confirmed declined on all three net-http versions (send_request_with_body_streamcopies throughIO.copy_stream); the conformance-suite and P8-9 documentation blockers dated; the Minitest 6 entry narrowed (no.stubanywhere).