Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
9231f14
feat: dexpace-async-thread — the bounded thread-pool executor (phase 8b)
Wahbeh-Mohammad Sep 21, 2026
53cbbf7
fix: a close from the pool's own threads completes instead of self-jo…
Wahbeh-Mohammad Sep 21, 2026
5f9d64b
fix: refuse a NaN, infinite or Complex duration and shutdown budget
Wahbeh-Mohammad Sep 21, 2026
57f75e3
fix: a delay's callbacks run under their own caller's diagnostic context
Wahbeh-Mohammad Sep 21, 2026
7771e65
fix: emit a task or delay defect's diagnostic under the caller's context
Wahbeh-Mohammad Sep 21, 2026
ccd53d0
test: dexpace-async-thread — the pool suites, doubles and composed run
Wahbeh-Mohammad Sep 21, 2026
394e838
test: guard the re-entrant close, the workers join and the lock scope
Wahbeh-Mohammad Sep 21, 2026
846a1ef
test: pin the finite-duration rejection and the diagnostics restore
Wahbeh-Mohammad Sep 21, 2026
7550bd7
test: pin the timer thread's context floor and bound the diagnostics …
Wahbeh-Mohammad Sep 21, 2026
730f714
test: pin the defect diagnostic's correlation and bound the gate waits
Wahbeh-Mohammad Sep 21, 2026
a26fdc6
docs: phase 8b — checklist, as-built page, ledger addendum and status…
Wahbeh-Mohammad Sep 21, 2026
907efd5
docs: phase 8b — review round 0's corrections, P8-76 and guards 29–32
Wahbeh-Mohammad Sep 21, 2026
a391cbe
docs: phase 8b — review round 1's corrections, P8-77 and guards 33–35
Wahbeh-Mohammad Sep 21, 2026
7735564
docs: phase 8b — review round 2's correction, P8-78 and guards 36–40
Wahbeh-Mohammad Sep 21, 2026
5755267
docs: phase 8b — review round 3's correction, P8-22 extended, guards …
Wahbeh-Mohammad Sep 21, 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
108 changes: 95 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ three-state PATCH — solved exactly once, and it deliberately does not compete
Work here is **spec-driven, not feature-driven**. `docs/product-spec/` is normative: 645 numbered requirements
across 19 prefixes. Before implementing anything, find the requirement IDs it must satisfy.

**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a and 8a are built — the whole of
**Phases 0, 1, 2, 3a, 3b, 4a, 4b, 4c, 5a, 5b, 5c, 6a, 6b, 6c, 7b, 7c, 7a, 8a and 8b are built — the whole of
phase 6, the whole of phase 7, whose three sub-phases were built concurrently off one base and landed in that
order, 7a last (umbrella #25 closes by hand), and the first of phase 8's three sub-phases, built off the same
base as phase 7's and reconciled onto the tree that holds all three; the domain model, the seam layer, the
order, 7a last (umbrella #25 closes by hand), the first of phase 8's three sub-phases, built off the same
base as phase 7's and reconciled onto the tree that holds all three, and the second, built off the tree that
holds 8a concurrently with 8c; the domain model, the seam layer, the
byte-streaming layer, the body layer, the execution context, the recovery layer, the stage pipeline, the
configuration layer, the tracing and metrics layer, the logging facade with its redaction, the retry layer,
the authentication layer, the redirect layer, the server-sent-events layer, the pagination layer, the
serialization layer, the synchronous transport and the conformance suite are the only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
serialization layer, the synchronous transport, the conformance suite and the thread-pool executor are the
only domain code.** Six gems exist under `gems/`, every one at `0.0.0`. `dexpace-core` carries the HTTP domain model — `Dexpace::Request`,
`Response`, `Headers`, `Status`, `Method`, `Protocol`, `MediaType`, `Query`, `RequestOptions`, `HeaderName`, the
`HeaderSyntax`, `PercentEncoding` and `URL` function modules, and the construction contract `Dexpace::Model` /
`Dexpace::Builder` under one error root, `Dexpace::Error`
Expand Down Expand Up @@ -223,9 +225,21 @@ the private `Checks`, `TransportCase` (with `DEFAULT_SETTLE`, `DEFAULT_WIRE` and
guard), `BorrowedPair`, the `WireServer` fixture with `RecordedRequest`, `JOIN_DEADLINE_SECONDS` and the
private `RequestReader`, the fifteen `Scripts`, `MinitestDriver` and the opt-in `RSpecDriver`, and the two
observability doubles `RecordingSpan` and `Allocations` (with `ATTEMPTS`), over the RBS interface `_Wire`
(`docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance-checklist.md`);
every other gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. The
synchronous transport is the first thing here that talks to a socket; nothing on the async path does yet. The workspace root
(`docs/work/mvp/phase8/phase8a/2026-09-11-phase8a-synchronous-transport-and-conformance-checklist.md`) —
**and the workspace's fifth real gem**, the async-runtime adapter in `dexpace-async-thread`,
`Dexpace::Async::Thread`: the fixed-size `Pool` over a bounded `::Thread::SizedQueue` — `.build(size:,
queue_limit:, shutdown_timeout:, name:, logger:, clock:)` with `size:` required and no default, `#post`
(`Dexpace::Page::_Executor` exactly, never blocking, `RejectedError` on a full queue and `ClosedError` on a
closed pool), `#delay` (a `Dexpace::Async::Future` settled with `true` on one lazily created timer thread),
`#size`, `#queue_limit`, `#name`, the three constants `QUEUE_DEPTH_PER_WORKER`, `DEFAULT_SHUTDOWN_TIMEOUT`
and `DEFAULT_NAME`, and `Dexpace::Closeable`'s latched `#close` that drains within one budget and emits
`Events::INSTRUMENTATION_SHUTDOWN` once — the `private_constant` `Timer` with its `Entry`, `Pool::Job`,
the two private field keys, `RejectedError`, `REQUIRED_CORE` and the require-time version-skew assertion
made directly because there is no executor registry
(`docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-checklist.md`);
the remaining gem's `lib/` still holds its namespace module and a `VERSION` constant and nothing else. The
synchronous transport is the first thing here that talks to a socket, and the pool is the first executor on
the async path — the two meet in `dexpace-async-thread`'s composed suite over a real socket. The workspace root
carries the `Gemfile`, `Rakefile`, `Steepfile`, `rbs_collection.yaml`, `.rubocop.yml`, `.yardopts`, `VERSIONS` and
the eighteen blocking gates — phase 0's seventeen
(`docs/work/mvp/phase0/2026-09-05-phase0-scaffold-and-quality-gates-checklist.md`) and phase 7b's
Expand Down Expand Up @@ -1110,6 +1124,69 @@ Each is one line plus the chapter to read before touching the area.
through an `untyped` local: rbs 4.2.0's `tcp_server.rbs` types the constructor
`(?String host, Integer port)`, an optional positional before a required one, which Steep 2.1.0
refuses for the two-argument call Ruby accepts.
- **A pooled worker clears its fiber storage at TWO boundaries — once at thread start and again after
every task — and the suite reads a worker's context through `Diagnostics.capture`, never the raw
map** — `::Thread.new` inherits the pool builder's `Fiber[]` at construction and `Diagnostics.with`
merges on install, so without the first clear a caller's task runs with the builder's `tenant`
underneath its own snapshot, and `.with` restores only `(prior.keys | snapshot.keys)`, so without the
second a key the block itself wrote survives onto the next caller's task; on the 3.2 floor a cleared
worker's raw storage is a map of nil-valued keys, which `Fiber[]`, `OBS-10`'s fold and `.capture` all
read as empty, and a proof that the second clear is present must write a key the worker has never held
(`ASYNC-9`, `ASYNC-10`; 8b's P8-20, P8-74). `.capture` carries core's `dexpace.current_span` slot with
the diagnostic keys, so a span active on the caller is current on the worker — `ASYNC-8`'s purpose —
and under the one-process `rake test:gems` another suite can leave a no-op span on the main fiber.
**The floor is EVERY `::Thread.new` a gem keeps, the lazily spawned ones included**: the pool's timer
thread is spawned by the first positive `#delay` from THAT caller's fiber, so without the same two
clears and a per-delay `Diagnostics.capture` on the `Timer::Entry` every later delay's `#on_settle` and
`#then` ran under the first caller's context — the design's own finding 1 at the gem's own door
(`ASYNC-8` names callbacks beside work; 8b's P8-78, review round 2). A callback the timer runs on
another thread's behalf — the shutdown handler on the closing thread — is installed and restored
through `Diagnostics.with` and never cleared, because that thread's storage is not the timer's.
- **`Pool#post` never blocks and never lets a bare stdlib error escape, and `#delay` settles with `true`**
— the submission queue is a `::Thread::SizedQueue` used with the NON-blocking push, so a full queue is
`RejectedError` (backpressure) and a closed pool `Dexpace::ClosedError` (a lifecycle bug), both
translated from the `ThreadError` and `ClosedQueueError` the queue raises at the one call site, which
is what makes `ASYNC-2`'s "saturated executor" a case this adapter has at all and lets a worker re-post
to its own pool without parking (P8-23); `Completer#fulfil(nil)` raises SEAM-16's refusal, so a
`#delay` future settles with `ELAPSED = true` as 5a's `Async.delay` does, and a zero delay settles
inline with no timer touched (P8-71). `#delay` and `.build` refuse a NaN, an infinite and a `Complex`
duration or `shutdown_timeout:` with `InvalidArgumentError` — `real?` before `negative?`, `finite?` after
it, `Numeric`'s own protocol — because a NaN answers false to both `negative?` and `zero?`, and one that
reached the timer's deadline-ordered list killed its thread and turned every later `#delay` into a bare
`ArgumentError`, while a NaN budget raised one out of `#close` with the latch flipped (P8-77; core's
`Async.validate_delay` and `Clock::Guard.duration` share the hole and are phase 10's). Phase 2's
`Bridge::AsyncOver` checks the token BEFORE dispatch as well as after, so a task cancelled while queued
never reaches the transport — every "window" test gates on the double's `entered` queue before
cancelling, or it is red three runs in twelve.
- **Both threads the pool owns rescue `::Exception` and never die, and `#close` is one bounded budget
for the timer stop and the worker drain** — a worker's `SystemExit`, `Interrupt` or `ScriptError` is
reported as an ERROR `http.instrumentation.hook` diagnostic inside `Instrumentation.contain` and the
pool is never one worker smaller (P8-22, which departs from `RECOV-2`'s re-raise because on a worker
"re-raise" means a silent death `report_on_exception` prints past every gate); the timer runs every
callback under the same net, because `Hooks.notify` re-raises a caller's raising `#on_settle` out of
`Completer#fulfil` on the timer thread; **both nets sit INSIDE `Diagnostics.with`, never around it**,
so the diagnostic is folded with the caller's snapshot still installed and carries the caller's
`trace.id` — a net around the `with` reports after the restore, with no id on the worker and the
timer and the CLOSER's id on the closing thread (review round 3's R3-1); `Timer#schedule` after
`#stop` refuses the entry through
`on_shutdown` so a close racing a delay spawns no thread; the drain carries a non-nil exit sentinel and
re-reads the clock because `Queue#pop` answers nil for a timeout, a close and a pushed nil alike; and
`#close` takes no `cancellation:` because `Dexpace.close_quietly` calls it with none (P8-24, P8-25,
P8-75). An `#on_settle` on a delay future runs on the TIMER thread, or on the closing thread when
`#close` fails it — and a `#close` issued THERE, or from inside a posted task, completes: neither
`Timer#stop` nor the drain ever joins the thread it is running on (`Thread#join` on the current
thread raises `ThreadError`, which as first cut escaped `#close` with the latch flipped, no event and
every other delay stranded; and a worker waiting for its own exit sentinel burned the whole budget),
so the timer thread exits once the handler returns and the closing worker counts as drained and
finishes its task afterwards (P8-76). The timer's mutex is never held across its `Queue#pop` wait, and
the hazard is NOT the per-fiber one the plan named — the wait runs on the timer thread, which has no
scheduler — but a cross-thread deadlock: `Timer#stop` parks on the mutex the parked thread holds and
`#close` hangs, which `pool_delay_test.rb`'s `LockScopeTest` turns into two bounded-join failures.
- **`test:gems`' one process makes every top-level test CLASS name unique across the six gems too, not
only the doubles** — core's `matrix_facts_test.rb` owns the bare `MatrixFactsTest` as a module, and a
second `class MatrixFactsTest` in an adapter gem aborts the whole run at load with "is not a module";
the pool gem's is `PoolMatrixFactsTest` and its doubles `PoolFakeTransport`, `PoolRecordingSink`,
`PoolStubClock` and `PoolProbeScheduler` (8a's checklist item 35, widened by 8b).

## Public API surface

Expand Down Expand Up @@ -1238,17 +1315,22 @@ probe compares each against the live tree, and a count written anywhere else in
in `sig/` and every one but `transport_suite/checks.rb`, `wire_server/recorded_request.rb` and
`wire_server/request_reader.rb` mirrored in `test/` — and its gemspec declares `dexpace-core` alone, its
`socket` and `tempfile` requires carried by the allowlist's exceptions.
Every other gem — `dexpace-async-thread` and `dexpace-transport-async_http` — is a phase-0 skeleton
`dexpace-async-thread`'s `lib/` holds the phase-8b async-runtime adapter — the entry file and three
files under `thread/`, `rejected_error.rb`, `pool.rb` and `timer.rb`, the last a `private_constant`,
every one mirrored in `sig/` and the two public ones in `test/` (`timer.rb` is proven through
`pool_delay_test.rb` and `pool_test.rb`'s source scans) — and its gemspec declares `dexpace-core`
alone, by design: the gem spends none of its `NFR-2` budget.
The other gem — `dexpace-transport-async_http` — is a phase-0 skeleton
whose `lib/` holds the namespace module and a `VERSION` constant and nothing else, and its gemspec
declares `dexpace-core` and no third-party gem yet (design P0-9); the third-party half of each `NFR-2`
declares `dexpace-core` and no third-party gem yet (design P0-9); the third-party half of its `NFR-2`
budget arrives with the phase that writes the code needing it, as 7a's and 8a's did.
- There are eleven phase directories under `docs/work/*/`; `mvp/` is the only delivery, and it holds
the v1 roadmap, `docs/work/mvp/2026-09-05-ruby-sdk-v1-roadmap-design.md`, plus `phase0/`,
`phase1/`, `phase2/`, `phase3/`, `phase4/`, `phase5/`, `phase6/`, `phase7/`, `phase8/`, `phase9/` and `phase10/`. `phase0/`, `phase1/` and `phase2/` each
carry that phase's design, plan and checklist; `phase3/` carries its segmentation design,
`docs/work/mvp/phase3/2026-09-08-phase3-segmentation-design.md`, and two sub-phase directories —
`phase3/phase3a/` and `phase3/phase3b/`, each holding that sub-phase's design, plan and checklist —
eighteen checklists written so far, each at implementation; `phase4/`
nineteen checklists written so far, each at implementation; `phase4/`
carries its segmentation design,
`docs/work/mvp/phase4/2026-09-08-phase4-segmentation-design.md`, and three sub-phase
directories — `phase4/phase4a/`, `phase4/phase4b/` and `phase4/phase4c/`; each holds that sub-phase's
Expand Down Expand Up @@ -1280,8 +1362,8 @@ probe compares each against the live tree, and a count written anywhere else in
`docs/work/mvp/phase8/2026-09-11-phase8-segmentation-design.md`, and three sub-phase
directories — `phase8/phase8a/` (synchronous transport and the conformance gem),
`phase8/phase8b/` (async-runtime adapter) and `phase8/phase8c/` (asynchronous transport);
each holds a design and a plan, and `phase8/phase8a/` a checklist too, written at implementation on
2026-09-20. Phase 8 is 52 IDs (`TRANSPORT-1`–`30`, `ASYNC-1`–`22`) and is
each holds a design and a plan, and `phase8/phase8a/` and `phase8/phase8b/` a checklist too, written at
implementation on 2026-09-20 and 2026-09-21. Phase 8 is 52 IDs (`TRANSPORT-1`–`30`, `ASYNC-1`–`22`) and is
the phase that ships the most gems in the roadmap — `dexpace-transport-net_http`,
`dexpace-async-thread`, `dexpace-transport-async_http` and `dexpace-conformance`, whose
gemspec, version and first release phase 8 owns. Its three sub-phases are independent, so
Expand Down Expand Up @@ -1324,5 +1406,5 @@ probe compares each against the live tree, and a count written anywhere else in
at `0.0.0`.
Every checklist but phase 0's, phase 1's, phase 2's, phase 3a's, phase 3b's, phase 4a's, phase 4b's,
phase 4c's, phase 5a's, phase 5b's, phase 5c's, phase 6a's, phase 6b's, phase 6c's, phase 7b's,
phase 7c's, phase 7a's and phase 8a's is still to be written at execution time.
phase 7c's, phase 7a's, phase 8a's and phase 8b's is still to be written at execution time.
- There are 40 harvested topics under `docs/knowledge/harvested/`; the harvest ran here on 2026-09-05.
12 changes: 9 additions & 3 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 and 8a 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 and 8b 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 @@ -93,8 +93,14 @@ 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
vacuous and waived rows, the plaintext wire fixture, the Minitest and RSpec drivers and the two
observability doubles (`docs/sdk-documentation/conformance.md`). The other two are still
skeletons — a namespace, a `VERSION`, a gemspec, a signature mirror and a smoke suite:
observability doubles (`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:

| Gem | Namespace | Runtime dependencies today |
|---|---|---|
Expand Down
10 changes: 7 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,11 @@ layer, phase 6a's retry layer, phase 6c's authentication layer, phase 6b's redir
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; the other two are phase 0's skeletons, a namespace and a
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 nineteen pages written so far are [`sdk-documentation/quality-gates.md`](./sdk-documentation/quality-gates.md), because the
[`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
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 @@ -146,7 +148,9 @@ JSON codec are the things phase 7a built, and
[`sdk-documentation/transport-net_http.md`](./sdk-documentation/transport-net_http.md) and
[`sdk-documentation/conformance.md`](./sdk-documentation/conformance.md), because the synchronous
transport and the conformance suite are what phase 8a built — the first code outside
`dexpace-core` after 7a's codec.
`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.

## Keeping this file true

Expand Down
Loading
Loading