Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
b609574
feat: per-gem Ruby floor in VERSIONS, read by the gates and the loaders
Wahbeh-Mohammad Sep 21, 2026
74e7acd
feat: the async-http transport adapter, Dexpace::Transport::AsyncHTTP
Wahbeh-Mohammad Sep 21, 2026
bd5866e
feat: conformance groups six and seven, Connection: close in Scripts
Wahbeh-Mohammad Sep 21, 2026
4b65575
fix: ignore the collection's stale async/2.12 signatures by name
Wahbeh-Mohammad Sep 21, 2026
245d9b8
fix: state what the async-http pessimistic constraint admits
Wahbeh-Mohammad Sep 21, 2026
984bfd9
fix: name the race in the portable mid-body cancellation row
Wahbeh-Mohammad Sep 21, 2026
1dcaeca
fix: swallow a cancel hook's push onto an exchange queue already closed
Wahbeh-Mohammad Sep 21, 2026
d4f4d77
test: the async-http adapter's suites, doubles and second driver
Wahbeh-Mohammad Sep 21, 2026
6379f10
test: hermetic configuration pins, the BINARY retag and the ensure's net
Wahbeh-Mohammad Sep 21, 2026
9f35aa6
test: pin the watcher's close by its cause, bound the async driver
Wahbeh-Mohammad Sep 21, 2026
c51da2c
test: a cancel racing the exchange's end, a forged body, the watcher …
Wahbeh-Mohammad Sep 21, 2026
6d445ad
docs: phase 8c record, the async transport's as-built page, index pages
Wahbeh-Mohammad Sep 21, 2026
79aaaf0
docs: P8-99's true reason, two guards made red, the page's forged exa…
Wahbeh-Mohammad Sep 21, 2026
66add05
docs: guard 37, the bounded driver, the portable row's race, the counts
Wahbeh-Mohammad Sep 21, 2026
58c327e
docs: the hook that outlives its exchange, four guards, the cause wrap
Wahbeh-Mohammad Sep 21, 2026
0fdedd9
chore: reconcile the 8c stack onto main after phase 8b
Wahbeh-Mohammad Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 71 additions & 30 deletions CLAUDE.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ group :development, :test do
end

# The workspace's own gems, by path, so `bundle exec` resolves them without an install: the six
# MVP skeletons under gems/, and any gem a later phase adds there.
# MVP gems under gems/, and any gem a later phase adds there. A gem whose own VERSIONS floor this
# interpreter does not meet is left out rather than handed to Bundler, which refuses a path gem's
# required_ruby_version at install time for the whole workspace (phase 8c's P8-36: only
# dexpace-transport-async_http, on the 3.2 row).
Dir.glob("gems/*", base: __dir__).sort.each do |dir|
next unless DexpaceVersions.gem_supported?(File.basename(dir))

gem File.basename(dir), path: dir
end
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ not compete with `faraday` or `httpx` on the easiest way to fetch a JSON endpoin

## Status

**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a and 8b are built.** Nothing is published. The repository holds six gems under
**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a, 8b and 8c are built.** Nothing is published. The repository holds six gems under
`gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — the frozen,
validated wire types every later phase stands on (`docs/sdk-documentation/http.md`) — the seam
layer: the provider registry, the transport and codec seams, the core-owned async pivot,
Expand Down Expand Up @@ -90,23 +90,31 @@ to a socket: two constructions over a fresh-per-call or a borrowed `Net::HTTP`,
the wire carries, a per-response producer thread behind every streamed body, one total budget
across three native knobs, a classifier that asks the cancellation token first and wraps every
other failure retryable, TLS settings and the proxy over the configuration chain
(`docs/sdk-documentation/transport-net_http.md`) — and `dexpace-conformance` carries the
conformance suite: the assertion protocol, the twenty-eight-assertion transport suite with its
(`docs/sdk-documentation/transport-net_http.md`) — `dexpace-transport-async_http` carries the
asynchronous transport, the first thing on the async path that talks to a socket: two
constructions over a reactor-keyed client map or a borrowed `Async::HTTP::Client`, one exchange task
per call under the caller's own reactor task with one total budget, the queue-marshalled
cancellation bridge that lets a token cancelled from any thread reach the exchange, the RFC 7230
token predicate applied on both protocols with once-per-name drop reporting, the lazy pull-shaped
response body, HTTP/2 by ALPN over TLS, and a Ruby floor of 3.3 that this gem alone declares
(`docs/sdk-documentation/transport-async_http.md`) — and `dexpace-conformance` carries the
conformance suite: the assertion protocol, the thirty-four-assertion transport suite with its
vacuous and waived rows, the plaintext wire fixture, the Minitest and RSpec drivers and the two
observability doubles (`docs/sdk-documentation/conformance.md`). `dexpace-async-thread` carries the
observability doubles, proven by both transports as its two drivers
(`docs/sdk-documentation/conformance.md`). `dexpace-async-thread` carries the
async-runtime adapter — the first executor on the async path: a fixed-size thread pool over a
bounded queue whose `#post` never blocks, the bridge that makes a blocking transport asynchronous
on a worker and closes an orphaned result exactly once, the diagnostic context carried across the
hop with the two clears that keep it the caller's, a scheduled delay on one timer thread, and the
idempotent bounded close that emits the lifecycle event phase 2 postponed
(`docs/sdk-documentation/async-thread.md`). The other one is still a
skeleton — a namespace, a `VERSION`, a gemspec, a signature mirror and a smoke suite:
(`docs/sdk-documentation/async-thread.md`) — and with that no skeleton remains: all six gems under
`gems/` carry their phase's code.

| Gem | Namespace | Runtime dependencies today |
|---|---|---|
| `dexpace-core` | `Dexpace` | none |
| `dexpace-transport-net_http` | `Dexpace::Transport::NetHTTP` | `dexpace-core`; `net-http >= 0.4` |
| `dexpace-transport-async_http` | `Dexpace::Transport::AsyncHTTP` | `dexpace-core` |
| `dexpace-transport-async_http` | `Dexpace::Transport::AsyncHTTP` | `dexpace-core`; `async-http ~> 0.104` (Ruby >= 3.3) |
| `dexpace-serde-json` | `Dexpace::Serde::JSON` | `dexpace-core`; `json >= 2.19.9` |
| `dexpace-async-thread` | `Dexpace::Async::Thread` | `dexpace-core` |
| `dexpace-conformance` | `Dexpace::Conformance` | `dexpace-core` |
Expand Down
16 changes: 15 additions & 1 deletion Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,21 @@ end
target :async_http do
check "gems/dexpace-transport-async_http/lib"
signature "gems/dexpace-transport-async_http/sig", "gems/dexpace-core/sig"
configure_code_diagnostics(D::Ruby.default)
# rbs's own stdlib signature sets for the two features this gem's lib/ names beyond core's
# allowlist: `openssl` for the default TLS context and `uri` for the request URL.
library "openssl", "uri"
# The second relaxation, on this target alone (phase 8c), for the same reason as
# :serde_json's: none of async, async-http, protocol-http or async-pool ships a sig/, and the
# one entry the collection carries for the closure -- `async/2.12`, whose Task declares `#stop`
# and neither `#cancel` nor `.current?` -- is ignored by name in rbs_collection.yaml because it
# would type the primitives this gem calls as missing, so every `::Async::HTTP::Client.new`,
# `::Protocol::HTTP::Request.new` and the `< ::Protocol::HTTP::Body::Readable` superclass is a
# Ruby::UnknownConstant that steep's default warning severity turns into a red gate. Downgraded
# to :information here, never a line-level ignore and never on core's strict target; every
# handle on the runtime is typed `untyped` in the gem's sig (NFR-11 admits no async-family type
# there either). Re-tighten to D::Ruby.default at the first release of those gems, or of the
# collection, that declares what this gem calls.
configure_code_diagnostics(D::Ruby.default.merge({ D::Ruby::UnknownConstant => :information }))
end

target :serde_json do
Expand Down
5 changes: 5 additions & 0 deletions VERSIONS
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# gem <gem name> <semver> read by that gem's gemspec and by tools/versions.rb
# tool <gem name> <constraint> read by the root Gemfile
# ruby floor <version> required_ruby_version in every gemspec (NFR-10)
# ruby floor:<gem> <version> one gem's own floor, when narrower than the global one
# ruby matrix <versions> the CI matrix; asserted against .github/workflows/ci.yml
# ruby dev <version> the development pin; asserted against .ruby-version
#
Expand Down Expand Up @@ -36,5 +37,9 @@ tool bundler-audit ~> 0.9
tool prism ~> 1.9

ruby floor 3.2
# P8-36 (phase 8c): async-http 0.95.0 and async 2.38.0 raised their floors to 3.3, so this one
# gem declares 3.3 and is absent from the bundle, test:gems and gates:clean_bundle on the 3.2 row.
# Colon-joined into the three-token `name` column so the grammar above needs no change.
ruby floor:dexpace-transport-async_http 3.3
ruby matrix 3.2 3.3 3.4 4.0
ruby dev 4.0.6
17 changes: 10 additions & 7 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,13 @@ layer, phase 6a's retry layer, phase 6c's authentication layer, phase 6b's redir
7b's server-sent-events layer, phase 7c's pagination layer, phase 7a's serialization layer and
phase 8a's transport error; `dexpace-serde-json` holds phase 7a's JSON codec and declares
`json >= 2.19.9`; `dexpace-transport-net_http` holds phase 8a's synchronous transport and declares
`net-http >= 0.4`, and `dexpace-conformance` phase 8a's assertion protocol, transport suite, wire
fixture, two drivers and two doubles; `dexpace-async-thread` holds phase 8b's thread pool, its
rejection error and the version-skew guard, and declares `dexpace-core` alone; the other one is
phase 0's skeleton, a namespace and a
`VERSION`. The as-built documentation lands in
[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the twenty pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
`net-http >= 0.4`; `dexpace-transport-async_http` holds phase 8c's asynchronous transport, declares
`async-http ~> 0.104` and a Ruby floor of 3.3 of its own, and `dexpace-conformance` phase 8a's
assertion protocol, transport suite, wire fixture, two drivers and two doubles, with phase 8c's two
assertion groups appended; `dexpace-async-thread` holds phase 8b's thread pool, its rejection error
and the version-skew guard, and declares `dexpace-core` alone. Every one of the six gems is real; no
phase-0 skeleton remains. The as-built documentation lands in
[`sdk-documentation/`](./sdk-documentation/) as each gem gains code; the twenty-one pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
gate set is the thing phase 0 built, [`sdk-documentation/http.md`](./sdk-documentation/http.md),
because the domain model is the thing phase 1 built,
[`sdk-documentation/seams.md`](./sdk-documentation/seams.md), because the seam layer is the thing
Expand Down Expand Up @@ -150,7 +151,9 @@ JSON codec are the things phase 7a built, and
transport and the conformance suite are what phase 8a built — the first code outside
`dexpace-core` after 7a's codec — and
[`sdk-documentation/async-thread.md`](./sdk-documentation/async-thread.md), because the thread pool
that is the async path's first executor is what phase 8b built.
that is the async path's first executor is what phase 8b built, and
[`sdk-documentation/transport-async_http.md`](./sdk-documentation/transport-async_http.md), because
the asynchronous transport, the suite's second driver, is what phase 8c built.

## Keeping this file true

Expand Down
32 changes: 27 additions & 5 deletions docs/first-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ them at `0.0.0` until the first release:
repository floor of 3.2 (phase 8c's deviation `P8-36`, whose per-gem Ruby floor gate edit is 8c's plan,
Task 3): `async-http` 0.104.0 and its whole
dependency closure require 3.3, and the last release allowing 3.2 is eleven minor versions behind the one
phase 8c verified against. **A consumer on Ruby 3.2 composes `dexpace-core`,
phase 8c verified against (built and proven on 0.105.0, 2026-09-21, with the floor read from one
`VERSIONS` row — `ruby floor:dexpace-transport-async_http 3.3` — by the gemspec, the gates, the
`Gemfile` and the two rake tasks that load the gem, so the 3.2 row installs, tests and clean-bundles
five gems and is green; on the 3.3 row the bundle compiles `openssl` 4.0.2, because the
interpreter's own 3.2.4 is older than `io-stream` requires — and a *networked* resolve, the
clean-bundle gate's scratch install included, picks the newest `openssl` gem on the 3.4 and 4.0 rows
too, as it picked `net-http` 0.9.1 over the default for 8a (`P8-60`), so the second extension's
toolchain need is confined to 3.3 only for an install that resolves the interpreter's own). **A consumer on Ruby 3.2 composes `dexpace-core`,
`dexpace-transport-net_http`, `dexpace-serde-json`, `dexpace-async-thread` and `dexpace-conformance`, and
loses only the reactor transport** — which is `NFR-2`'s separability paying for itself, and it should be
stated in the release notes rather than discovered at `bundle install`.
Expand All @@ -61,8 +68,14 @@ stated in the release notes rather than discovered at `bundle install`.
the suite exists — twenty-eight assertions over twenty-two `TRANSPORT` IDs, `HTTP-17`/`HTTP-18` and
`PAGE-36` — and 8a ran it against `dexpace-transport-net_http` on 3.2.11 (net-http 0.4.1 and 0.9.1),
3.3.12, 3.4.10 and 4.0.6, every assertion green and `TRANSPORT-18` vacuous by measurement; the
`async-http` half waits for 8c
- [ ] **Before release, `docs/sdk-documentation/` must state what a green `dexpace-conformance` run does
`async-http` half waits for 8c. **Status 2026-09-21**: 8c ran it against
`dexpace-transport-async_http` on 3.3.12, 3.4.10 and 4.0.6 — thirty-four assertions now, phase
8c's two groups appended — with every assertion green and four skips accounted for by name: the
two assertions under `TRANSPORT-14` and the one under `TRANSPORT-27` waived by id (`P8-38`,
`protocol-http1` refuses both heads out of the read) and `TRANSPORT-18` vacuous by measurement
as on 8a's adapter; on the 3.2 row the async half is not installable and the sync half is what
runs, as this line already says
- [x] **Before release, `docs/sdk-documentation/` must state what a green `dexpace-conformance` run does
and does not prove, and the run's own report preamble must name the same omissions.** Filed
2026-09-12 by phase 8a's design (`P8-9`). The wire fixture speaks plaintext only and exercises no
connect timeout, so `TRANSPORT-4`'s open-timeout half and every TLS property are asserted in
Expand All @@ -75,7 +88,10 @@ stated in the release notes rather than discovered at `bundle install`.
badge. Cites `TRANSPORT-4`, `TRANSPORT-14`, `TRANSPORT-20`, `NFR-2`; the suite itself is phase
8a's Tasks 4–8 and 20 and phase 9's Tasks 2–12a. **Status 2026-09-20**: `TransportSuite::PREAMBLE`
names the two omissions in every report, and `docs/sdk-documentation/conformance.md` states them
beside what a green run proves; the box ticks when 8c's waiver is stated beside them
beside what a green run proves; the box ticks when 8c's waiver is stated beside them. **Ticked
2026-09-21**: the preamble names a third omission, `TRANSPORT-8`, whose antecedent only an
adapter's own suite can originate, and `conformance.md` states 8c's two waivers (`TRANSPORT-14`
and `TRANSPORT-27`, both by id, both this adapter's alone) beside the three omissions
- [ ] **Before release, `docs/sdk-documentation/` must document the `include Dexpace` constant-shadow
hazard.** Filed 2026-09-08 by phase 3a's design; recorded here 2026-09-13. Phase 1 measured
`Dexpace::Method` shadowing `::Method` and concluded "**verified inert outside core**"; the observation
Expand Down Expand Up @@ -130,7 +146,13 @@ stated in the release notes rather than discovered at `bundle install`.
time** — `Clients::MAX_ORIGINS`, drained back to the cap in a loop after each insert, with each
evicted client's pool closed — so the map is expected to be bounded the moment `8c` lands and
this blocker to close then, without a phase-10 repair. Phase 9's routing assumed `8c` had
already run; it had not. This line stays open until a green `gates:bounded_map` run confirms it
already run; it had not. This line stays open until a green `gates:bounded_map` run confirms it.
**Status 2026-09-21**: 8c landed the bound — `Clients::MAX_ORIGINS` (32) over a key of
(reactor, origin), drained back to the cap in a loop after every insert with closed reactors
evicted first, every evicted client's pool retired and closed — proven by the three `XCUT-14`
cases in `gems/dexpace-transport-async_http/test/dexpace/transport/async_http/clients_test.rb`;
the gate itself does not exist yet, so the line stays open for phase 9's `gates:bounded_map` run
and nothing else
- [ ] **Before release, `docs/sdk-documentation/` carries one worked end-to-end example** — a
generated-style client over `dexpace-core` + `dexpace-transport-net_http` + `dexpace-serde-json`:
operation descriptor, request assembly, pipeline with an AUTH step, decode, typed error, one
Expand Down
29 changes: 29 additions & 0 deletions docs/knowledge/notes/concurrency-and-async.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,32 @@ entry's stable key.
SDK two answers to one question, and `close_quietly`'s contract is phase 2's. Cites `SEAM-30`, `ASYNC-5`,
`ASYNC-6`, `TRANSPORT-7`, `TRANSPORT-9`, `TRANSPORT-22`, `CFG-21`, `XCUT-13`.
<sub>review · `docs/work/mvp/phase8/2026-09-11-phase8-segmentation-design.md` · high · sha:manual-phase8-async-cancel-not-standarderror</sub>
- **`Async::Task#cancel` cannot be called from another OS thread, keeps only an `Exception` as its
`cause:`, and on `async` 2.46 `Kernel#Async` inside a task is that task's child.** Beside this file's
`## Superseded` entry on `#cancel`, which stands, and beside `concurrency-and-async/f4beb429`
(`ASYNC-22`'s "safe for concurrent calls from multiple threads"); three facts execution measured on
`async` 2.46.0 under Ruby 3.3.12, 3.4.10 and 4.0.6 that phase 8c's design stated otherwise or not at all.
**One.** `Task#cancel` on a task whose fiber is not the current one calls `Fiber.scheduler.raise`, and
`Fiber.scheduler` is nil on every OS thread but the reactor's, so a cancellation hook that reaches the
task directly from the canceller's thread raises `NoMethodError: private method 'raise' called for nil`
on that thread and leaves the task running (`:running` afterwards, measured). A cancellation that may
originate on any thread — a `Dexpace::Cancellation::Source#cancel`, which the conformance suite fires
from a `Thread.new` — therefore has to be marshalled into the reactor: phase 8c pushes the reason onto a
`Thread::Queue` that a transient watcher task inside the reactor pops (a scheduler-aware wait, wakeable
from any thread) and the watcher cancels the exchange on the reactor's own thread. **Two.**
`Task#cancel(cause:)` keeps the cause only when it is an `Exception`; anything else — the design's
fact 8 passed a Symbol — is replaced by the runtime's own `Async::Cancel::Cause` ("Cancelling task!"),
so a reason travels as an exception (`Dexpace::CancelledError.new(reason)`) if the cancelled task's
own `$!.cause` is to name it for whoever reads the task; phase 8c's adapter reads no cause back —
its pivot is settled with the reason before the watcher cancels the task — so the wrap is for the
task tree and a debugger, not a channel the SDK relies on. And both of the adapter's hooks may run
AFTER the exchange has ended: `Cancellation::Source#cancel` and `Async::Completer#settle` each
steal their hook list under their mutex and run it outside, so a push onto the exchange's queue
has to be total over the queue's close (`ClosedQueueError` rescued at the push), or the raise
travels back through `Hooks.notify` into the caller's `Source#cancel` on the cancelling thread —
reproduced deterministically with an ordinary caller hook registered first. **Three.** `Kernel#Async`
inside a running task delegates to `Task.current.async`, so the spawned task **is** the current
task's child (`inner.parent.equal?(task)` measured true); the design's fact 10 ("`Async { }` inside a
reactor is not a child of the caller") does not hold on 2.46.0, and the adapter's `caller_task.async`
spelling is the honest one rather than a distinction the runtime still draws. Cites `ASYNC-6`, `ASYNC-22`, `TRANSPORT-7`, `TRANSPORT-8`.
<sub>review · `docs/work/mvp/phase8/phase8c/2026-09-11-phase8c-asynchronous-transport-checklist.md` · high · sha:manual-phase8c-cancel-across-threads</sub>
Loading
Loading