Skip to content

Phase 8b: async-runtime adapter — documentation and phase record - #95

Merged
Wahbeh-Mohammad merged 15 commits into
mainfrom
31-phase-8b-async-runtime-adapter-docs
Sep 22, 2026
Merged

Wahbeh-Mohammad merged 15 commits into
mainfrom
31-phase-8b-async-runtime-adapter-docs

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

Closes #31. Third PR of phase 8b's stack — the phase record and the documentation — on top of #94. Umbrella #29 stays open: 8c remains.

What lands

11 files, +1,177 / −35.

  • docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-checklist.md — nineteen own rows (seventeen ✅; ASYNC-3 ⏳ citing docs/first-release.md's unsatisfied-MUST entry; ASYNC-4 N/A citing §10.5) plus fifteen cross-reference rows (PIPE-33 re-asserted ⏳ on its phase-4 row, ASYNC-6, SEAM-12, SEAM-18, SEAM-25, XCUT-11, XCUT-13, XCUT-22, OBS-20, …); the roadmap's legend verbatim; sections: requirement rows, what was built, matrix facts re-run on every interpreter, guards run red (43), audit groups run, deviations from the plan (41), findings routed, postponed work.
  • The design's As-built addendum, ledger rows P8-71–P8-78 beside the design's own P8-20–P8-25 (P8-22 and P8-25 extended in place; the charter's bands hold — 8c's as-built rows start at P8-91). The consolidation into design §10 is a human's — docs/sdk-design-ruby/ is frozen.
  • docs/sdk-documentation/async-thread.md (new; every block executed on 4.0.6 and 3.2.11 — eleven blocks, 56 checks, extracted mechanically and run as one script by the final reviewer), architecture.md, the gem's README (with "Where callbacks run" — a #delay future's on_settle runs on the timer thread, or on the closing thread when #close fails it), README.md, docs/README.md (the twentieth page written).
  • docs/knowledge/notes/observability.md — the 8b Reference entry filed on 2026-09-12 amended in place under its own manual sha with the as-built facts (both boundaries cleared, the capture read, the floor's retained-nil keys, the reserved dexpace. slots travelling with the snapshot); nothing duplicated.
  • docs/first-release.md — the existing unsatisfied-MUST entry gains one dated status sentence (ASYNC-3 ⏳ and PIPE-33's interrupt clause re-asserted, as forecast; ASYNC-4 N/A on §10.5 alone). Nothing else needed 8b.
  • CLAUDE.md — the built-phases sentence gains 8b ("the second, built off the tree that holds 8a concurrently with 8c"); the opening paragraph gains the fifth real gem; the skeleton clause loses dexpace-async-thread (one skeleton remains); a "Constraints that will bite" line for the one-process test:gems rule extended to test class names (PoolMatrixFactsTest); nineteen checklists; the phase8/ sentence (8a and 8b hold checklists); counts unchanged where 8b adds no core file (two hundred and twenty lib files, nineteen private_constant exceptions).
  • The roadmap's phase-8b status note (append-only, with its five review-round paragraphs — the design's findings 2 and 3 recorded as closed by phases 2 and 0 before this phase; the composed test's home; SEAM-25 landed, its harness half phase 9's Task 11), and two dated phase-10 inbound bullets (the main fiber's storage residue under the one-process runner; the plan's Task 7 Step 8 premise).

docs/product-spec/, docs/sdk-design-ruby/, docs/knowledge/harvested/, docs/deviations.md and every other phase's documents — 8a's, 8c's and phase 7's included — are untouched. No file under gems/dexpace-core, gems/dexpace-transport-net_http, gems/dexpace-conformance, gems/dexpace-transport-async_http, tools/, tasks/, Steepfile, rbs_collection.yaml, VERSIONS, Gemfile or .github changed anywhere in the stack.

Verification

Docs tip: bundle exec rake (eighteen gates) green on 4.0.6; ruby .claude/skills/housekeeping/probe.rb exit 0 (all eight checks); ruby scripts/verify_knowledge_structure.rb OK; the housekeeping and knowledge tooling suites green; surface:regenerate with no diff; the new page's blocks run on 4.0.6 and 3.2.11.

Known follow-ups

The two routed inbound bullets and the recorded equivalent mutants — see the code PR's list.

Build the async-runtime adapter: Dexpace::Async::Thread::Pool, a fixed-size
::Thread pool over a bounded ::Thread::SizedQueue that is the first real
implementation of SEAM-18's caller-supplied executor duck type and of
Dexpace::Page::_Executor, settles phase 2's core-owned pivot from a worker
thread, carries ASYNC-15..ASYNC-17's lifecycle over Dexpace::Closeable with
SEAM-25's shutdown event, performs the ASYNC-8..ASYNC-12 diagnostic hop
over Fiber[], and ships ASYNC-18's scheduled delay on one timer thread.

Pool.build(size:, ...) creates exactly size workers and never grows; #post
never blocks the caller (a full queue is RejectedError, a closed pool
Dexpace::ClosedError, both translated from the queue's own errors at the
one call site and routed to the failure channel by phase 2's bridge,
P8-23); #delay answers a Dexpace::Async::Future settled with true, as 5a's
Async.delay does, because Completer#fulfil(nil) raises (P8-71); #close
closes the queue, stops the private Timer and fails its outstanding
delays, drains the worker exit sentinels and joins the workers within one
shutdown_timeout budget, then emits Events::INSTRUMENTATION_SHUTDOWN once
(P8-24). A worker clears its inherited fiber storage at thread start and
again after every task so Diagnostics.with installs the caller's snapshot
rather than merging it onto the pool builder's or the previous task's
(P8-20); both threads the pool owns rescue ::Exception and report the
defect as a contained diagnostic instead of dying (P8-22, extended to the
timer, whose #schedule after #stop refuses the entry, P8-75). The entry
file asserts Dexpace::VERSION against REQUIRED_CORE at require time,
because SEAM-18 leaves no executor registry to register into (P8-21,
P8-72). Timer is a private_constant with a hooks.rbs-style sig mirror.

The smoke suite preloads core before its snapshot and pins the four public
constants; the surface manifest gains the object model's twelve rows and
no private one. The gemspec is unchanged: dexpace-core and nothing else.
…ining

Phase 8b, review round 0's R0-2 and R0-8. A Pool#close issued from a
#delay future's on_settle handler -- which runs on the timer thread, as
the README documents -- reached Timer#stop's thread.join on the current
thread: ThreadError escaped #close with the latch already flipped, no
shutdown event was emitted and every other outstanding delay stayed
unsettled forever (measured on 4.0.6 and 3.2.11). Timer#stop now skips
the join when the thread is the current one; the wake queue is closed
regardless, so the next pop answers nil and #run exits as soon as the
handler returns, and the leftovers are failed on the closing thread as
on any other.

The same self-wait, one thread over: a #close issued from inside a
posted task waited for its own worker's exit sentinel, which cannot be
pushed until #release returns, so every such close burned the whole
shutdown budget and reported drained: false. drain_workers now counts
the calling worker as exited and never joins it; that worker exits by
itself once the task returns, draining whatever the closed queue still
holds first. Recorded as ledger row P8-76 on the docs branch; the YARD
on #delay and #release states both outcomes.
Phase 8b, review round 1's R1-1. Pool#delay validated a duration as
Numeric and not negative, and a NaN answers false to both negative? and
zero?: it reached the timer, whose list is ordered by deadline. The NaN
deadline made next_wait's max raise inside the timer thread's net -- one
diagnostic, the thread gone for good with @thread still set, the future
never settled -- and every later #delay on that pool raised a bare
ArgumentError synchronously from sort_by! (measured on 4.0.6 and
3.2.11). A Complex is a Numeric with no order and no #negative?, a bare
NoMethodError from the same method.

validate_duration! now asks real? before negative? and finite? after
it, so a NaN, an infinite or a Complex duration raises
Dexpace::InvalidArgumentError before anything is scheduled (ASYNC-18's
"MUST reject", P8-77). finite? is Numeric's own protocol and covers a
Float or BigDecimal NaN or infinity with one call, which is why Infinity
is refused with NaN: an entry that never fires is not a delay. The same
shape guarded shutdown_timeout: a NaN budget made #release's deadline
arithmetic raise a bare ArgumentError out of #close with the latch
already flipped, and an infinite one is the unbounded close XCUT-13
forbids for this gem by name, so non_negative_numeric! becomes
finite_non_negative! (real, non-negative and finite), renamed in the sig.
Phase 8b, review round 2's R2-1. The timer thread is the gem's second
::Thread.new carrier, and it inherited the FIRST #delay caller's fiber
storage at creation and was never cleared and never handed a per-delay
snapshot: every later delay's #on_settle and #then callback ran under a
stale, foreign context -- caller B's handler and a contextless caller
C's both read caller A's trace id, and a key one handler wrote was
visible to every later one (measured on 4.0.6 and 3.2.11). ASYNC-8
names callbacks explicitly, and the design's own finding 1 names this
mechanism as ASYNC-10's stale assembly-time snapshot; only the workers
had the floor P8-20 states.

Timer#schedule now captures Diagnostics.capture per entry, on the
scheduling caller's thread inside Pool#delay (ASYNC-10's per-submission
point); the Entry carries the snapshot; the timer thread clears its
inherited storage once at start and again after every fired callback,
which runs under Diagnostics.with over the entry's snapshot (ASYNC-8,
ASYNC-9) -- the shape Pool#run already has, applied to the second thread
the gem owns (P8-78, extending P8-20). The shutdown callback runs on
whichever thread stopped the timer under the same install-and-restore,
so the closer's own context is put back and never cleared: that thread
is not the timer's to empty. The sig declares the new member and the
three private methods; no public surface changes.
The gem's one log event emitted on a caller's behalf after the hop --
P8-22's ERROR http.instrumentation.hook for a posted block or a delay
handler that raised -- was emitted after Diagnostics.with had restored
the thread, so on the worker and on the timer alike it carried no
trace id (payload keys exactly [cause, event] on 4.0.6 and 3.2.11),
while a line the block itself logged inside the hop carried the id.
ASYNC-8's stated purpose is that events emitted after the hop retain
correlation, and this was the one event the gem itself emits there
(review round 3's R3-1).

Pool#run now rescues inside the Diagnostics.with block, so
report_failure runs with the task's snapshot still installed and the
ensure clear stays at method level; Timer#fire and Timer#shut_down
nest guarded inside Diagnostics.with rather than around it, so the
timer's on_error runs under the delay caller's snapshot on the timer
thread and on the closing thread. Nothing outside either net can
raise -- .with's install and restore are Fiber[]= over the Symbol keys
.capture read off a fiber's storage -- so both threads' survival stays
structural. The sig files are unchanged.
Nine suites under test/dexpace/async/thread/ and five doubles under
test/support/, every one a top-level constant unique across the six gems
(PoolFakeTransport, CountingResponse, PoolRecordingSink, PoolStubClock,
PoolProbeScheduler; PoolMatrixFactsTest, because core owns the bare name
and rake test:gems loads every gem's suite into one process).

pool_test.rb proves construction, the three source scans (two bounded
spawn sites, none of the forbidden calls, every Ruby constant ::-qualified),
#post's worker hop and non-blocking push, the worker net that survives
exit, Interrupt, NoMemoryError and a raising sink, and #close's idempotent
latch, drain, one event and spent budget off a stub clock;
pool_diagnostics_test.rb the ASYNC-8..ASYNC-12 hop with the pool built in a
different context from the one it is driven from, every worker-side read
through Diagnostics.capture (the 3.2 floor retains nil-valued keys) and an
ensure-clear proof over a key never held; pool_delay_test.rb ASYNC-18's
four clauses, the entry-list observable a cancel must empty, the timer
under close and its containment, and a two-fiber liveness check under a
probe scheduler; bridge_test.rb ASYNC-1, 2, 5, 7, 13, 14, 15(b), 16, 17,
19, 20 and PIPE-33's four met clauses over a real worker, every window
gated on the double's entered queue because phase 2's bridge checks the
token before dispatch; page_executor_test.rb the first real
Dexpace::Page::_Executor, bridged over an inline executor so only the
paginator's executor: can move the consumer; pool_concurrency_test.rb the
XCUT-11 evidence; and composed_transport_test.rb the charter's convergence
point 2 — 8a's Dexpace::Transport::NetHTTP through
Transport.async_over(adapter, executor: pool) against dexpace-conformance's
WireServer, the one file in the gem that opens a socket and the one that
requires test/support/net_http_warmup (P8-62). Twenty-eight guards run red
on 4.0.6 and 3.2.11; every wait is bounded and no test sleeps.
Phase 8b, review round 0. Four guards the round found missing or wrong,
each run red against its mutation on 4.0.6 and 3.2.11:

- R0-2: "close from a delay's on_settle handler returns nil, emits once,
  fails the rest" -- close issued on the timer thread; with the
  self-join check removed the handler reports ThreadError.
- R0-8: "close from inside a task returns before the budget, drained;
  the worker exits" -- with the calling worker counted and joined the
  outcome pop times out at a quarter of the budget.
- R0-3: "close joins the workers after their sentinels, so none is
  alive when it returns" -- the exit queue is replaced by one whose
  push parks the worker after its sentinel is visible, so deleting the
  join leaves both workers provably alive when close returns (the GVL
  hid that gap from every plain thread count); the construction and
  20-cycle tests gain the same postcondition.
- R0-1: LockScopeTest, a cancel and a close issued from helper threads
  while the timer is provably parked, both with bounded joins -- the
  plan's Task 7 Step 8 mutation (the mutex held across the wait) is red
  by two reported failures here, where every other test in the file is
  red by a hang through teardown's close; the SchedulerTest comment that
  called that mutation green is corrected.

pool_test.rb's three drain tests move into a DrainTest class under the
class-length limit; shutdown_events moves to the base.
Phase 8b, review round 1. Three P8-77 cases in the delay suite: a NaN
duration raises InvalidArgumentError before any timer thread exists and
a later delay on the same pool still fires (under the previous
validation nothing was raised, the timer thread died and every later
delay raised a bare ArgumentError); an infinite duration is refused as
non-finite and a negative infinity as negative; a Complex raises
InvalidArgumentError where the previous check raised NoMethodError. One
construction case pins shutdown_timeout refusing NaN, either infinity
and a Complex by name. All four are red against the previous pool.rb on
4.0.6 and 3.2.11.

The diagnostics suite's teardown now asserts the restore it performs --
the main fiber's compacted storage reads exactly as it did before setup
-- which is the teardown-order assertion the design's testing strategy
(group 4) names and the suite did not carry (review round 1's R1-3).
…pops

Phase 8b, review round 2. R2-1: five cases in the diagnostics suite
drive the timer thread as the gem's second carrier -- a first caller
spawns it, later callers' #on_settle handlers read their own context
and a contextless caller's reads empty; the first handler to fire does
not see the spawning caller's keys (the construction floor, isolated
from the reuse floor); a key a handler writes is invisible to the next
handler; a #then continuation on a timer another request spawned reads
the delay caller's context; and the entry #close fails settles under
its caller's context on the closing thread, whose own context is put
back after it. All five are red against the previous timer.rb on 4.0.6
and 3.2.11, and one each turns red under the four mutations of the
fixed one (the thread-start clear removed, the after-callback clear
removed, the callback run bare, the snapshot captured at Timer.new).

The suite is reshaped into a test-less base holding setup, teardown
and the readers, with WorkerContextTest and TimerContextTest nested
beside each other -- the delay suite's shape -- so the timer cases
inherit no worker case; one worker test's name is shortened to fit the
column. R2-3: every queue pop in the file is bounded, so a mutant that
never runs a task fails an assertion instead of tripping Ruby's
deadlock detector. R2-2: the composed suite's one reason-less disable
goes, the call it covered split across two statements.
Review round 3's R3-1: the P8-22 defect diagnostic is emitted under the
caller's context. pool_test.rb's ScriptError case posts under
{trace.id: DEFECT-7} and asserts the id on the hook payload;
pool_delay_test.rb's ContainmentTest schedules under DEFECT-8 (the
timer thread) and DEFECT-9 with the close issued under CLOSER (the
closing thread) and asserts each. Against the round-3 lib on 4.0.6 and
3.2.11 the first two read nil and the third reads "CLOSER" -- the
shutdown diagnostic carried the closer's correlation, not the delay
caller's; green after the nesting fix.

R3-2: every main-thread gate wait in bridge_test.rb, pool_test.rb,
composed_transport_test.rb, pool_concurrency_test.rb and
page_executor_test.rb is pop(timeout: 5) with refute_nil, the helper
threads' pops are bounded and their joins are join(5) with refute_nil,
as pool_diagnostics_test.rb has done since R2-3; the gate pops inside
task blocks are the tests' own controls and stay. A mutant that never
runs the worker now fails an assertion instead of ending in Ruby's
deadlock detector or hanging under the WireServer's accept thread.
… note

The phase record for the async-runtime adapter, written from what was
built: the checklist with one row per ID (seventeen ✅, ASYNC-3 ⏳ citing
docs/first-release.md's unsatisfied-MUST entry, ASYNC-4 N/A on §10.5, the
PIPE-33 and ASYNC-6 cross-reference rows and the rows execution added),
the twenty-eight guards run red with their messages and rows, the audit
groups, thirty-five deviations from the plan, the findings routed and the
postponed-work mark; the design's As-built addendum, P8-71..P8-75 with
P8-22 and P8-25 extended in place; docs/sdk-documentation/async-thread.md,
the twentieth as-built page, every example run on 4.0.6 and 3.2.11 as one
script; the gem README rewritten for a built gem with ASYNC-7's section;
architecture.md, README.md and docs/README.md pointing at the page;
CLAUDE.md's built-phases paragraph, skeleton clause, checklist count and
constraints list re-derived from the tree; the roadmap's status note and
two dated inbound bullets for phase 10; one dated status sentence in
docs/first-release.md's unsatisfied-MUST entry; and the observability
note's 8b entry amended in place under its own marker. docs/deviations.md
is untouched, for phase 10 to flip; docs/sdk-design-ruby/ is frozen.
Phase 8b, review round 0. R0-1 (blocking): the design addendum, the
checklist (deviation 13, "Findings routed"), the roadmap's inbound
bullet and status note all recorded the plan's Task 7 Step 8 mutation
-- the timer's mutex held across its queue wait -- as measured green.
It is red: Timer#stop parks on the mutex the parked timer thread holds,
#close never returns and the delay suite hangs in teardown, three runs
in three with a thread dump; the plan's mechanism (a per-fiber
ThreadError) is what does not occur, because the wait runs on the timer
thread. The addendum paragraph is rewritten to say so, the checklist's
guards table gains row 29 with the dump, the phase-10 bullet routed on
the false premise is removed before it reaches main, and the status
note says why.

R0-2 and R0-8: ledger row P8-76 -- a close issued from the pool's own
threads completes, neither the timer's stop nor the drain joining the
thread it runs on -- with guards 30 and 31, the ASYNC-15, ASYNC-16,
ASYNC-18, SEAM-25 and XCUT-11 rows updated, deviation 36, the README's
lifecycle table and "Where callbacks run", the as-built page's #close
section (a grace-period example, run on 4.0.6 and 3.2.11) and a
CLAUDE.md constraint. R0-3: guard 32 and deviation 29 say the workers
join is now pinned. R0-4: 115 runs in the gem, 3,809 in test:gems.
R0-5: P8-75's identity-removal clause reworded as intent over an
equivalent mutant. R0-6: the page declares e, sink, fake and strategy.
R0-7: the README's ASYNC-7 contrast no longer states the reactor
adapter's behaviour as shipped.
Record review round 1's repairs where each lives. The design's As-built
addendum gains P8-77: #delay refuses a NaN, an infinite or a Complex
duration beside a negative or non-Numeric one, and .build refuses the
same shapes for shutdown_timeout, where the design's clause table and
object model named only the first two checks -- a NaN answers false to
both negative? and zero?, reached the timer's deadline-ordered list,
killed its thread and turned every later #delay into a bare
ArgumentError, and a NaN budget raised one out of #close with the latch
flipped. The checklist gains the round's paragraph, the ASYNC-18 and
XCUT-13 rows' clauses, guards 33-35 (the two validation mutations and
the teardown-restore mutation, red on 4.0.6 and 3.2.11), deviations 37
and 38, the corrected ASYNC-2 test citation (R1-2), the renamed helper
and the run counts (119 in the gem, 3,813 under test:gems). The
teardown-order assertion the design's testing strategy names (R1-3) is
recorded as built. Core's Async.validate_delay and Clock::Guard.duration
share the hole and go to phase 10's inbound list by date and content.
The gem README, the as-built page (the two new examples run on 4.0.6 and
3.2.11 as one script with the rest, 55 checks) and CLAUDE.md's
constraint bullet state the widened contract.
The design's As-built addendum gains P8-78 -- the timer thread carries
a delay's diagnostic context the way a worker carries a task's, where
the timer sketch, R11 and the class comment gave the gem's second
::Thread.new carrier no context discipline and P8-20's floor was stated
for the workers alone -- and P8-25's extension names the context a
delay handler runs under. The checklist gains the round-2 paragraph,
the timer half on the ASYNC-8, ASYNC-9, ASYNC-10 and ASYNC-12 rows,
guards 36-40 (the four mechanisms each removed alone, and the round-2
timer.rb as a whole, all red on 4.0.6 and 3.2.11), deviations 39 and
40, the reshaped diagnostics suite and the run counts (124 in the gem,
3,818 under test:gems). The gem README's context and callbacks sections
and the as-built page state where a delay callback's context comes
from, the page with a three-delay example run on both rows (58 checks
as one script); CLAUDE.md's two-boundary bullet names the lazily
spawned carrier; the observability note's 8b entry is amended in place
under its own sha with the rule restated for every ::Thread.new a
library keeps; the roadmap's status note carries the round.
…41–43

Review round 3's should-fix (R3-1): the P8-22 defect diagnostic, the
one log event the gem emits on a caller's behalf after the hop, was
emitted after Diagnostics.with had restored the thread and so carried
no trace id on the worker and the timer, and the closer's id on the
closing thread. The checklist gains the round-3 paragraph, the
correlation clause on its ASYNC-8 row, guards 41-43 (each nesting put
back alone, each caught by the one assertion that guards it) plus the
reviewer's mutation 6 re-run against the bounded suites, deviation 41
and the bounded-wait sentence (R3-2); the design's addendum extends
P8-22 a second time rather than numbering a row; the gem README's
"Where callbacks run", the as-built page, CLAUDE.md's P8-22 bullet and
the roadmap's status note each state the diagnostic's correlation; the
observability note's 8b entry is amended in place under its own marker
with the corollary for a carrier's own emissions. No run count changes
(124 in the gem, 3,818 under test:gems); no requirement row's mark
moves.
@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:transport Transport, async model, seams: TRANSPORT-* ASYNC-* SEAM-* labels Sep 22, 2026
@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor Author

Review record for the phase 8b stack (#93 → #94 → #95)

5 independent reviews, each by a fresh agent with no memory of the previous one, each re-running every gate itself on every tip (4.0.6 every gate individually at the code tip and the full rake at the tests and docs tips; the matrix set on 3.2.11, 3.3.12 and 3.4.10) and applying its own mutations on both interpreters, with a fix round by a fresh agent between each. The stack was cut from main at a7cfeb6.

Round Verdict Blocking Should-fix Nits Mutations (caught) Disposition
0 changes required 1 2 5 38 (34) all 8 addressed in fix round 1
1 changes required 0 1 2 43 (40) all 3 addressed in fix round 2
2 changes required 1 0 2 52 (48) all 3 addressed in fix round 3
3 changes required 0 1 1 47 (43) all 2 addressed in fix round 4
4 (final) approve 0 0 0 42 (41) carried into the PR bodies

Nothing was skipped.

Round 0 → fixed in round 1

  • R0-1 blocking — docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-design.md:2015: False claim: the plan's Task 7 Step 8 mutation (timer mutex held across the pop) does NOT stay green — it deadlocks #close and the suite hangs. Fixed on 31-phase-8b-async-runtime-adapter-docs (and -tests for the comment and the new guard) (67c4b96): Reproduced first: X5 applied on the tests tip hangs -n /two.fibers/ 3/3 under a 20 s watchdog; thread dump: main in Timer#stop timer.rb:97 Mutex#synchronize via Pool#stop_timer/#release/Closeable#close/PoolDelayTest#teardown, timer thread holding the mutex in
  • R0-2 should-fix — gems/dexpace-async-thread/lib/dexpace/async/thread/timer.rb:104: Pool#close called from a #delay future's on_settle (the documented timer thread) raises ThreadError, skips the shutdown event and leaves every other outstanding delay unsettled forever. Fixed on docs (53cbbf7): Timer#stop: thread&.join(timeout) unless thread.equal?(::Thread.current); the wake queue is closed regardless so #run exits once the handler returns; leftovers failed on the closing thread. Measured: close from a delay's on_settle → [:returned, nil], closed?
  • R0-3 should-fix — gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb:386: The bounded workers join in drain_workers (P8-75's last clause, checklist deviation 29) is unpinned: removing it survives every run. Fixed on 31-phase-8b-async-runtime-adapter-tests (+ docs) (631e480): DrainTest 'P8-75: close joins the workers after their sentinels, so none is alive when it returns': @Exits replaced by a Queue subclass whose << parks the worker after the sentinel is visible, released 0.25 s later from a helper thread; X4 (join removed) red 3
  • R0-4 nit — docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-checklist.md:113: Two run counts in the docs are wrong: the gem's suite is 111 runs, not 116; test:gems is 3,805 runs, not 3,804. Fixed on docs (67c4b96): Re-derived after the round's four new tests: the gem suite is 115 runs / 551 assertions (111 at round 0), test:gems is 3809 on 4.0.6 and 3.2.11 (3805 at round 0). Checklist 'What was built' and the roadmap status note updated; the test:gems figure verified by
  • R0-5 nit — docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-design.md:1996: P8-75's Data#== rationale for removing entries by identity is unreachable. Fixed on docs (67c4b96): P8-75's clause now reads: entries are removed by identity as a statement of intent, not a guarded property — every entry carries two lambdas built for it alone, so Data#== is already identity in practice and a delete by equality is an equivalent mutant (the re
  • R0-6 nit — docs/sdk-documentation/async-thread.md:86: Three page examples use names the page never declares (e, sink, fake/strategy). Fixed on docs (67c4b96): The page's shorthand sentence now declares e (the rescued RejectedError), sink (an in-memory _Sink taking Logger's block form, PoolRecordingSink is one), fake (a lambda answering a 200 Response) and strategy (a two-page _Strategy over #parse(response, template
  • R0-7 nit — gems/dexpace-async-thread/README.md:58: README states the 8c gem's behaviour in the present tense on a base where it is a skeleton. Fixed on docs (67c4b96): README: 'a reactor-backed adapter is designed to abort at the next scheduler checkpoint instead (ASYNC-7; design §3.3's contrast, the behaviour dexpace-transport-async_http is specified to carry)' — tense-neutral, so it stays true before and after 8c lands.
  • R0-8 nit — gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb:375: A #close issued from inside a worker task always burns the whole shutdown budget and reports drained: false. Fixed on docs (53cbbf7): drain_workers counts the calling worker as exited (private other_workers) and joins the others only; that worker exits by itself after the task, running what the closed queue still holds first. Measured: close from a task returns in 0.0 s with drained: true (w

Round 1 → fixed in round 2

  • R1-1 should-fix — gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb:249: Pool#delay(Float::NAN) kills the timer thread and makes every later #delay raise a bare ArgumentError. Fixed on docs (5f9d64b): Reproduced first with the reviewer's exp_nan.rb on the unfixed code tip (4.0.6): delay(NaN) settled nothing, the timer thread died after one http.instrumentation.hook diagnostic, and delay(0.05) then raised ArgumentError 'comparison of Float with 33161.25… fai
  • R1-2 nit — docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-checklist.md:64: The ASYNC-2 row misquotes one test name. Fixed on docs (9042b3a): The ASYNC-2 row now quotes "SEAM-15: a queue closed under an open latch surfaces as ClosedError, not the raw one" exactly as pool_test.rb names it.
  • R1-3 nit — docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-design.md:1867: Testing-strategy group 4's 'teardown-order assertion proves the restoration itself' has no counterpart in the suite and no deviation records its absence. Fixed on 31-phase-8b-async-runtime-adapter-tests (+ docs) (c46a9fb): Took the reviewer's first option: pool_diagnostics_test.rb's teardown asserts assert_equal(@prior_storage, Diagnostics.capture) after the per-key restore and before super — the design's testing-strategy group 4 'teardown-order assertion proves the restoration

Round 2 → fixed in round 3

  • R2-1 blocking — gems/dexpace-async-thread/lib/dexpace/async/thread/timer.rb:124: The timer thread inherits the FIRST #delay caller's Fiber[] and never clears or installs anything, so every later delay's #on_settle / #then callback runs under a stale, foreign diagnostic context. Fixed on docs (57f75e3): Reproduced first with scratchpad/phase8b/fixer3/exp_timer_context.rb at the old code tip on 4.0.6 and 3.2.11: A → {trace.id: CALLER-A, tenant: a-corp}, B → the same, C (no context) → the same plus the handler-written key, a #then on request 2 → A's context, th
  • R2-2 nit — gems/dexpace-async-thread/test/dexpace/async/thread/composed_transport_test.rb:81: One rubocop:disable without a reason. Fixed on tests (f41da22): composed_transport_test.rb's ASYNC-1 case now assigns the future on one statement (the argument list wrapped across two lines) and calls .value(deadline:) on the next; the Layout/LineLength disable is gone. Honest RuboCop clean at every tip (672 / 686 files).
  • R2-3 nit — gems/dexpace-async-thread/test/dexpace/async/thread/pool_diagnostics_test.rb:70: The diagnostics suite's queue pops are unbounded, unlike every other suite in the gem. Fixed on tests (f41da22): Every queue pop in pool_diagnostics_test.rb carries timeout: 5 (the observed_on_worker helper, the two gates, the result queues, the three-context sequence through Array.new(2) { pop(timeout: 5) }, the ASYNC-12 and empty-storage cases); a nil from a timed-out

Round 3 → fixed in round 4

  • R3-1 should-fix — gems/dexpace-async-thread/lib/dexpace/async/thread/pool.rb:335: The gem's own defect diagnostic (P8-22's http.instrumentation.hook) is emitted AFTER Diagnostics.with has restored the thread, so it carries no trace id — on the worker and on the timer alike. Fixed on docs (7771e65): Reproduced with the reviewer's exp_defect_context.rb at the round-3 tip on 4.0.6 and 3.2.11 (hook payload keys [cause, event], trace.id nil for both; the in-block log line REQ-9). Fix: Pool#run's rescue ::Exception => error; report_failure(error) moved insid
  • R3-2 nit — gems/dexpace-async-thread/test/dexpace/async/thread/bridge_test.rb:146: Main-thread waits in the bridge, pool and composed suites are still unbounded, although review round 2's R2-3 rationale said every suite but the diagnostics one bounds its waits. Fixed on tests (730f714): Every main-thread gate wait bounded with refute_nil and a message: bridge_test.rb :62 (seen.pop), :102, :146, :186, :223, :318 (entered/occupied), the ASYNC-14 canceller's entered.pop bounded and returning its value so the main thread asserts both join(5) and

Round 4 (final) — approve

What the final reviewer verified by experiment, both interpreters

  • R3-1 re-verification (exp_defect_context.rb): a raising posted block under REQ-7, a log line inside a block under REQ-9, a raising delay handler under REQ-8, a raising shutdown handler scheduled under — Code tip: three ERROR hook diagnostics with payload keys [cause, event, trace.id] carrying REQ-7, REQ-8 and REQ-10; the in-block line REQ-9; closer context {} afterwards; one thread alive after close. Round-3 lib: keys [
  • Can Diagnostics.with's install or restore raise over a .capture snapshot, now that both nets sit inside it? (exp_storage_keys.rb) — Fiber#storage= raises TypeError for a String or an Integer key on every row; Fiber["str"]= raises TypeError on 3.2/3.3 and interns to a Symbol on 3.4+ — storage keys are always Symbols, so the install and the union resto
  • F.2.1 Diagnostics.capture vs raw Fiber.current.storage on a worker and on the timer thread after both clears, with a key the task/handler itself wrote (exp_worker_storage.rb) — capture {} on both rows for both threads; raw {} on 4.0.6 and {trace.id: nil, tenant: nil, leaked_by_work: nil} / {…, leaked_by_handler: nil} on 3.2.11 — P8-74 as stated.
  • F.2.2 the clearing-line mutations per carrier (1, 2 on the worker; 36, 37 on the timer) — 1 → 4 failures, 2 → 1 failure (:leaked_by_work), 36 → 1 failure, 37 → 1 failure (:leaked_by_handler), on both rows.
  • F.2.3 a raising #on_settle on a delay future with the timer's net (ContainmentTest; the probe's REQ-8 case) and without it (mutation x6) — With the net the later delay settles true and the hook diagnostic reaches the sink with the caller's id; without it the later delay expires with CancelledError deadline_expired.
  • F.2.4 whole-workspace test:gems at every tip (4.0.6 ×4 seeds, 3.2.11, 3.3.12, 3.4.10) grepped for 'already initialized constant' and 'leaked a thread'; each pool-gem file alone under ruby -w on both r — 0 of either in every test:gems section; per-file 124 runs with 0 warnings on 3.2.11 and 4.0.6.
  • F.2.5 Fiber.set_scheduler with PoolProbeScheduler as shipped and with #fiber_interrupt undefined (exp_scheduler_warn.rb) — 4.0.6 warns 'Scheduler should implement #fiber_interrupt' only without it (0 vs 1); 3.2.11 silent either way.
  • F.2.6 the composed exchange Transport.async_over(NetHTTP.build(timeout: 5), executor: pool) over WireServer.start(Scripts.fixed(…)) (the as-built page's socket block inside docs_examples_r4.rb) — 200, 'hello from the wire', 'GET / HTTP/1.1' on both; Thread.list delta 0 on 4.0.6 and +1 on 3.2.11 with no warm-up required (P8-62).
  • F.2.7 steep, rbs_surface, surface_snapshot at every tip; surface:regenerate at the docs tip; sig one-for-one with lib; grep for Fiber in sig/ — All green; regeneration produced no diff; sig mirrors lib one file per file (timer.rbs with the private-constant comment); no sig names Fiber; only dexpace-async-thread.txt differs from main (+12 rows).
  • F.2.8 DEXPACE_CLEAN_BUNDLE_GEM=/gems/dexpace-async-thread rake gates:clean_bundle — 1 gem loads in isolation with core beside it; no tasks/gates.rake edit needed.
  • F.2.9 twenty build-and-close cycles with a positive delay each (pool_concurrency_test, ten runs per row) — Thread.list.size delta 0 and no worker alive after any close, every run (200 cycles per row).
  • F.2.10 the plan's Task 7 Step 8 mutation (Timer#run holds its mutex across @wake.pop) against LockScopeTest — Red in 5 s: 'the cancel blocked on the timer's mutex while it was parked — Expected nil to not be nil' — never green, as the corrected documents state.
  • F.2.11 test/gates/gem_layout_test.rb and require_allowlist_test.rb by hand — 4 runs/44 assertions green; 22 runs/62 assertions with the gate suite's own 4 floor skips; 142 gate runs green on 4.0.6 at every tip.
  • F.2.12 the gem's own rake test, default seed and SEED=4242 — 124 runs, 640 assertions, 0 failures, 0 skips, both.
  • R3-2 by experiment: the reviewer's mutation 6 (blocking @Queue << job) against pool_test, bridge_test, pool_concurrency_test and composed_transport_test whole, under timeout 150 each — Every suite ends in a printed summary within ~66 s total: pool_test 28 runs / 2 failures, bridge_test 17 runs / 1 error, pool_concurrency_test 7 runs / 1 failure, composed 10 runs green; the only deadlock-detector text i
  • Mechanical constraint scan (scan.rb) over the 19 .rb files under the gem at the tests tip — Both headers on lines 1-2 of every file; lib requires are dexpace and five require_relatives only; no sleep/Timeout/Thread#raise/#kill/Thread.current[]/storage=/Regexp/downcase/Time.parse/**splat/force_encoding/URI/Enu
  • Checklist test-name citations against the tree (cite_check.rb) — 82 citation-shaped strings against 124 test names: 75 resolve; the seven that do not are quotations of requirement or design text, one paraphrase with an ellipsis, and the pre-reshape name deviation 40 says was renamed;
  • CLAUDE.md count sentences re-derived from the docs tip's tree — 19 checklists; 20 sdk-documentation pages beside architecture.md; 221 core lib files (220 beside version.rb) — unchanged; the pool gem 5 lib / 5 sig / 16 test-tree files; 6 gems; 11 phase dirs; 40 harvested topics; every
  • The docs tree for another lane described as landed — Every mention of 8c / dexpace-transport-async_http in the stack's diff is 'skeleton', 'specified to carry', 'concurrently with 8c' or a ledger-band reference; nothing describes it as built. docs/knowledge/notes/observabi

Tips reviewed at the final round: code 7771e65, tests 730f714, docs 5755267 on main a7cfeb6.

@Wahbeh-Mohammad
Wahbeh-Mohammad changed the base branch from 31-phase-8b-async-runtime-adapter-tests to main September 22, 2026 10:37
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 2eb185d into main Sep 22, 2026
5 checks passed
@Wahbeh-Mohammad Wahbeh-Mohammad mentioned this pull request Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:transport Transport, async model, seams: TRANSPORT-* ASYNC-* SEAM-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 8b: Async Runtime Adapter

1 participant