Skip to content

Phase 8a: synchronous transport — the Net::HTTP adapter and the conformance gem - #90

Merged
Wahbeh-Mohammad merged 6 commits into
mainfrom
30-phase-8a-synchronous-transport-and-conformance
Sep 21, 2026
Merged

Wahbeh-Mohammad merged 6 commits into
mainfrom
30-phase-8a-synchronous-transport-and-conformance

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Part of #30. First PR of phase 8a's three-PR stack — the code — built off main at c53638b (phases 0–6) concurrently with 7a #26, 7b #27 and 7c #28, reviewed and approved there, then rebased and re-proven onto main at 734e6b3 (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::HTTP transport adapter, the conformance gem, and the phase-level Dexpace::TransportError — 89 files, +4,853 / −46 on top of phase 7's main — TRANSPORT-1–6, 10, 11, 14–20, 22, 24–30 (twenty-two ✅, TRANSPORT-28's zero-copy clause ⏳ under docs/first-release.md; TRANSPORT-12/13 are 8c's).

  • dexpace-transport-net_http becomes a real gem — the gemspec's net-http >= 0.4 (a default gem declared as the NFR-2 third-party half; newest under Bundler by the manager's decision, so CI runs 0.9.1 on every row while --local keeps each interpreter's default: the adapter is proven on 0.4.1, 0.6.0 and 0.9.1 and matrix_facts_test.rb prints the active Net::HTTP::VERSION per row). Dexpace::Transport::NetHTTP with .default (fresh per call) and the Adapter (.build(tls:, timeout:, …), .owning / .borrowing), the ResponsePump (one producer thread per response; #head_or_raise takes 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-slot SizedQueue, never a Mutex, returned by the producer's own ensure, and the producer never breaks out of read_body so 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 retryable TransportError before any head, P8-54), the RequestMapper (HeaderSyntax.validate_name! / .validate_outbound_value! run again immediately before dispatch — HTTP-17, HTTP-18, XCUT-18), the ResponseMapper (TRANSPORT-14 drops a malformed inbound header by name under Events::TRANSPORT_HEADER_DROPPED; an unparseable Content-Length is deleted inside the head block before read_body can raise Net::HTTPHeaderSyntaxError on the producer; a Content-Length beside Transfer-Encoding: chunked is the -1 sentinel — round 3's R3-1, a real defect: #each / #write_to / #to_replayable had truncated the chunked body to the bogus length while body_string read all of it; bounded fifteen-digit Regexp.new(…, timeout:) length grammars), Deadline (open/read/write_timeout from RequestOptions, Keys::REQUEST_TIMEOUT the ninth key; the MIN_TIMEOUT_SECONDS clamp), Failures (every non-SDK failure wrapped in Dexpace::TransportError answering #retryable? with a #phase — the blind spot 6a's P6-4 named is closed for this adapter, and docs/first-release.md's blocker is ticked), ProxyRoute (the first consumer of 5a's Proxy.resolve; PROXY_LIMITATION_EVENT for the 401 clause TRANSPORT-30 declines) and TlsSettings (the tls: keyword, plain values typed untyped for NFR-11).
  • dexpace-conformance becomes a real gem — the assertion protocol phase 0 postponed: Failure, Vacuous, Assertion, Result (STATUSES five), Report (a PREAMBLE naming the two omissions below in every report), the TCPServer WireServer on 127.0.0.1:0 with RecordedRequest and a private RequestReader (await_closed_connection(count = 1, timeout:) bounded, P8-56; the per-handler join under JOIN_DEADLINE_SECONDS), Scripts (fifteen functions; .fixed / .large take hold:), TransportCase / TransportSuite (run(build:, borrow:, waive:, around:, settle:, wire:, assertions:), every assertion in a fresh case torn down in ensure), RecordingSpan over 5c's _Span, Allocations.delta (the ATTEMPTS agreement loop; GC re-enabled only if it was enabled), and the two thin drivers — MinitestDriver (.drive / .build_case) and RSpecDriver (never required by the entry file; ::RSpec resolved at call time). Twenty-eight assertions in five groups: outbound (7), inbound (4), streaming (3), retry/cancellation/failure (8 — TRANSPORT-18 vacuous by measurement, reported as a Minitest skip by design §9.3), lifecycle/concurrency/cross-phase (6, incl. 7c's PAGE-36 per-call-options test and 5c's OBS-21 / OBS-25 obligations). No TRANSPORT-12/13 row: the antecedent is measured absent on net-http (P8-55).
  • In core: 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. tempfile joins RequireAllowlist::ALLOWED (one reviewed line naming TRANSPORT-28, with its positive-control fixture; the socket denial's per-gem scope was already phase 0's). clean_bundle_check scopes the scratch bundle's BUNDLE_PATH under 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. The Steepfile's :conformance target gains library "socket", "tempfile". Every raw Net::HTTP the suites build passes an explicit nil proxy, never :ENV (round 2's R2-1 — an uppercase HTTP_PROXY on the host had aborted both gem suites at load); test/support/net_http_warmup.rb parks Ruby's process-wide Timeout thread at both adapter gems' test-helper load, because net-http below 0.7 opens its connection under Timeout.timeout (P8-62). Core's registry pins that an adapter's require-time registration invalidates in the one test:gems process run in a bare child (test/support/bare_require.rb, transport_bare_require_test.rb).
  • sig/ mirrors lib/ one file per file across all three gems (the fifteen private_constants with hooks.rbs's comment); the manifests regenerated once — core 1,330 → 1,335 (+5), dexpace-transport-net_http 2 → 17, dexpace-conformance 2 → 100, the other three unchanged — every row read against the object model; Adapter.owning, Adapter.borrowing and MinitestDriver.build_case are public @api private rows (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_check took core_path:; the per-gem socket denial); the version-bound net-http facts (0.9.1 stamps no default Content-Type and connects through TCPSocket.open(open_timeout:), not Timeout.timeout) are P8-61/P8-62; Transport.async_over over a real socket is 8b's; the adapter's sink double is NetHTTPRecordingSink, because every top-level test-support constant must be unique across the six gems' suites in one process.

Reconciliation onto phase 7's main

The rebase met three code conflicts, all resolved inside the commits that caused them: lib/dexpace.rb keeps 7b's, 7c's and 7a's blocks then 8a's; dexpace_test.rb keeps every layer pin and the merged PhaseSevenLayers class; and seam_surface_test.rb is resolved to main'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's BareRequire module and transport_bare_require_test.rb stay 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), the Steepfile and tasks/gates.rake auto-merged to exactly 8a's hunks beside 7a's :serde_json block and 7b's gates:serde_boundary task, and docs/first-release.md to exactly 8a's seven hunks. Task 19c's codec half is un-guarded (705d864, tests branch): the dead skip … 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-built ProtocolError carries #response, not the 4b design's #headers/#body) — so the tests tip's skip count is exactly one. 8a's clean_bundle_check BUNDLE_PATH scoping closes the phase-10 bullet 7a filed on 2026-09-20 (dated bracket appended), and the reconcile pass paid 7a's owed gems/dexpace-core/README.md serialization paragraph. Every file only 8a touched is byte-identical to the reviewed tip; every file only main touched is byte-identical to main.

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

  • Independent review, five rounds by five fresh reviewers with a fix round between each — the four-review cap was reached with round 3 at 0 blocking / 2 should-fix / 2 nits, and the manager chose one targeted round because R3-1 was a real defect (6c's precedent): round 0 0 / 4 / 8; round 1 0 / 2 / 2; round 2 0 / 3 / 1; round 3 0 / 2 / 2; round 4 approve, 0 / 0 / 0. Code changes from review: bounded Content-Length regexps and the Allocations GC state (round 1), the pump built over a cancelled token (round 2), the warm-up client's explicit nil proxy (round 3), the chunked-length sentinel (round 4); every other finding was a test or docs gap, each closed by a mutation-proven case.
  • All gates individually at this tip on 4.0.6 (seventeen on the old base, eighteen after the rebase); the matrix set on 3.2.11 under net-http 0.4.1 and 0.9.1, and on 3.3.12 and 3.4.10 at the tests tip; honest RuboCop clean; probe clean at the docs tip — each run by the reviewers on the old base and again by the reconcile pass and the manager's pre-push checks on the rebased tips.
  • Mutations: 50, 48, 55, 52 and 39 by the five reviewers on 4.0.6 and 3.2.11; the survivors are recorded equivalents (the belt-and-braces closed-pump pair, each half alone; outbuf via clear + <<).
  • By experiment, both interpreters, through the real adapter against WireServer: the chunked-with-Content-Length body 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 retryable TransportError with phase: :connect; a 4 MiB round trip byte-identical to a raw Net::HTTP fetch; a full TransportSuite.run through 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)

  • R0-5 (accepted) — the tests tip's skip count: TRANSPORT-18's measured vacuity is reported as a Minitest skip by MinitestDriver per design §9.3 (one skip; the 19c codec half's guard is gone with the rebase).
  • R0-12 (deferred) — the implementer's three commit subjects are 75, 84 and 103 characters; never amended, and the squash titles are the PR titles.
  • Routed to phase 10's inbound list: net-http below 0.7's connect-phase Timeout.timeout starting a process-wide thread; rbs 4.2.0's TCPServer#initialize signature (the untyped local in WireServer); 6b's REDIR-23 ten-second wall-clock bound failing under machine load; 6a's RETRY-42 / RECOV-28 eight-thread test erroring in 2 of 5 whole-process 3.2.11 runs; whether phase 1's Status should admit 000–999 (a 999 head is an InvalidArgumentError today, with the connection released); the two child-process idioms for one registry property (7a's private helper and 8a's BareRequire).
  • docs/first-release.md: TRANSPORT-28's zero-copy clause confirmed declined on all three net-http versions (send_request_with_body_stream copies through IO.copy_stream); the conformance-suite and P8-9 documentation blockers dated; the Minitest 6 entry narrowed (no .stub anywhere).

…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.
@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 21, 2026
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit a599f9d into main Sep 21, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 30-phase-8a-synchronous-transport-and-conformance branch September 21, 2026 07:30
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