As built by phase 8b, in dexpace-async-thread, written against source on 2026-09-21. This page
says what the zero-third-party async driver gives an SDK author today: 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,
the producer that settles phase 2's core-owned pivot from a worker thread, the ASYNC-15–ASYNC-17
lifecycle over Dexpace::Closeable with SEAM-25's shutdown event, the ASYNC-8–ASYNC-12
diagnostic hop over Fiber[], and ASYNC-18's scheduled delay on one timer thread. What each is
required to do is docs/product-spec/18-asynchronous-runtime-adapter-contract.md (ASYNC-1–ASYNC-20,
of which this gem owns nineteen; ASYNC-6, ASYNC-21 and ASYNC-22 are the asynchronous transport's);
how the design maps it to Ruby is docs/sdk-design-ruby/03-seam-by-seam-idiomatic-mapping.md §3.3 and §3.7
read with entries 3, 4 and 5 of docs/sdk-design-ruby/10-deliberate-deviations-from-the-reference-contract.md;
the per-requirement proof is docs/work/mvp/phase8/phase8b/2026-09-11-phase8b-async-runtime-adapter-checklist.md.
Signatures live in gems/dexpace-async-thread/sig/, and this page does not restate them. Every example
below was run, in the order printed and as one script, against the built code on 4.0.6 and 3.2.11 and
printed the same on both, except for Hash#inspect's spelling, the wording of a NoMethodError and the
last line's thread count (the socket block's one connection starts net-http 0.4.1's process-wide Timeout
thread on the 3.2 row, and nothing on 4.0.6; docs/sdk-documentation/transport-net_http.md has the fact).
Seven names are the page's shorthand and nothing else is assumed: Pool is Dexpace::Async::Thread::Pool;
EMPTY is Dexpace::RequestOptions::EMPTY; NONE is Dexpace::Cancellation.none; req(url) is
Dexpace::Request.build(method: :get, url: url, headers: Dexpace::Headers::EMPTY); e is the
RejectedError the line before it raised, rescued; sink is an in-memory _Sink — the eight duck-typed
methods, #debug/#info/#warn/#error taking stdlib Logger's block form and appending the rendered
Hash the block yields to #events, and the four predicates answering true (the gem's
PoolRecordingSink is one); fake is a lambda answering a plain 200 Response for any
request; and strategy is a Dexpace::Page::_Strategy whose #parse(response, template) answers two
pages — [1, 2] with the template as the next request, then a terminal [3] — the gem's
page_executor_test.rb has both in full. pool is the two-worker pool the first block builds and the
last block closes.
The gem's whole dependency budget is dexpace-core (NFR-2), by design: a thread is the runtime
every Ruby already has, so the gem spends none of its third-party half. Requiring it registers nothing:
SEAM-18 requires the executor to be caller-supplied with no default, so there is no executor registry
to register into, and the entry file asserts at require that the loaded core satisfies
REQUIRED_CORE ("~> 0.0") instead, raising Dexpace::SeamError naming both versions on skew.
require "dexpace/async/thread"
pool = Pool.build(size: 2, name: "docs")
pool.size # => 2
pool.queue_limit # => 16
pool.name # => "docs"
pool.owned? # => true
pool.closed? # => false
Pool::QUEUE_DEPTH_PER_WORKER # => 8
Pool::DEFAULT_SHUTDOWN_TIMEOUT # => 30.0
Pool::DEFAULT_NAME # => "dexpace-async-thread"
Pool.build(size: 0) # raises Dexpace::InvalidArgumentError:
# "size must be a positive Integer, got 0"
Pool.build(size: 2, queue_limit: "8") # raises Dexpace::InvalidArgumentError:
# "queue_limit must be a positive Integer, got \"8\""
Pool.new(size: 1) # raises NoMethodError: private method 'new' called ....build creates exactly size threads, each named "<name> worker N", and the pool never grows or
shrinks: a worker cannot die (below), so there is nothing to replace, and a pool whose thread count
changes has a capacity a caller cannot reason about. size: has no default for the reason SEAM-18
refuses a default executor: the right number is a function of the caller's service and its connection
budget, and a wrong guess is starvation. queue_limit: is a depth per worker, derived after size is
validated so Pool.build(size: nil) names the keyword rather than raising from the default;
shutdown_timeout: (a finite, non-negative Numeric — a budget is a bound), name: (a non-empty String), logger: (anything
answering #event; Dexpace::Instrumentation::Logger::NULL by default) and clock: (anything answering
#monotonic; Dexpace::Clock::SYSTEM) validate the same way, each naming its keyword.
done = Thread::Queue.new
pool.post { done << Thread.current.name } # => nil
done.pop # => "docs worker 0"#post is Dexpace::Page::_Executor's signature exactly — one zero-arity block, void — and returns
nil: a handle returned through an interface that says the value is not to be used would be a surface
in one NFR-4 artifact and not the other. It never parks the caller, a pool worker re-posting to its
own pool included: the queue's non-blocking push turns a full queue into a raise rather than a blocked
producer, which is what makes ASYNC-2's "saturated executor" a case this adapter has at all.
tiny = Pool.build(size: 1, queue_limit: 1, name: "tiny")
gate = Thread::Queue.new
entered = Thread::Queue.new
tiny.post { entered << :in; gate.pop } # occupies the one worker
entered.pop
tiny.post { nil } # fills the one queue slot
tiny.post { nil } # raises Dexpace::Async::Thread::RejectedError:
# "tiny: queue full (limit 1, 1 workers)"
# rescued into `e`:
e.is_a?(Dexpace::Error) # => true
e.is_a?(IOError) # => false
e.respond_to?(:retryable?) # => false
gate << :go
tiny.close
tiny.post { nil } # raises Dexpace::ClosedError: "tiny is closed"Two errors for two conditions, because a caller's two sensible responses differ: a full queue is
backpressure to retry or shed, a closed pool is a lifecycle bug. RejectedError is a StandardError
under Dexpace::Error and not an IOError — a rejection is not a transport failure — and it answers
no #retryable?, because a pool rejection happens at the bridge, above the pipeline, where no retry
step can see it. Neither the ThreadError a full push raises nor the ClosedQueueError a closed queue
raises ever escapes lib/.
transport = ->(request, _options, _cancellation) { "#{Thread.current.name} sent #{request.url}" }
async = Dexpace::Transport.async_over(transport, executor: pool)
future = async.call(req("https://example.test/pets"), EMPTY, NONE)
future.class # => Dexpace::Async::Future
future.value(deadline: Dexpace::Clock.deadline_in(5)) # => "docs worker 0 sent https://example.test/pets"
closed = Pool.build(size: 1, name: "shut")
closed.close
failed = Dexpace::Transport.async_over(transport, executor: closed).call(req("https://example.test/"), EMPTY, NONE)
failed.settled? # => true
failed.value # raises Dexpace::ClosedError: "shut is closed"Transport.async_over(transport, executor: pool) is phase 2's bridge, and the pool is the first
executor it has ever had. #call mints a Completer, returns the future before anything fallible, and
posts one block; a raise from #post itself — a closed pool, a full queue — is routed to the failure
channel, so a caller of an async seam never rescues around #call (ASYNC-2). The block runs the
send on the worker, re-checks the token on return, and settles the completer; a failure the transport
raised arrives as the identical exception object, because there is no wrapper to unwrap (ASYNC-13).
The bridge is owned: false: closing it leaves the pool open and usable (ASYNC-15, XCUT-22).
source = Dexpace::Cancellation.source
gate, entered, closes = Thread::Queue.new, Thread::Queue.new, Thread::Queue.new
result = Object.new
result.define_singleton_method(:close) { closes << :closed; nil }
slow = ->(_request, _options, _cancellation) { entered << :in; gate.pop; result }
future = Dexpace::Transport.async_over(slow, executor: pool).call(req("https://example.test/slow"), EMPTY, source.token)
entered.pop # the worker is inside the send
source.cancel(:changed_my_mind) # => true
future.settled? # => false: nothing interrupted the worker
gate << :go # the send returns, after the cancel
future.value(deadline: Dexpace::Clock.deadline_in(5)) # raises Dexpace::CancelledError, reason :changed_my_mind
closes.pop(timeout: 2) # => :closed -- exactly once
closes.pop(timeout: 0.1) # => nilThis is ASYNC-7's documented outcome for the thread adapter, and the contrast the reactor-backed
adapter draws the other way: an in-flight blocking read runs to completion, the result the caller can
no longer take delivery of is closed exactly once (ASYNC-5, whoever loses the produce-versus-cancel
race), and the future settles as cancelled with the caller's reason. A task cancelled while still queued
never reaches the transport at all — the bridge checks the token before it dispatches as well as after —
and a response already delivered is never closed by a later cancel (ASYNC-20): the caller owns closing
what they were handed. Thread#raise and Thread#kill are forbidden throughout the SDK (design §8.3),
so ASYNC-3's interrupt mode is an unsatisfied MUST this gem carries and does not re-open; a transport
blocked in an uninterruptible read occupies its pool slot until the read returns on its own.
Fiber[:"trace.id"] = "abc123"
seen = Thread::Queue.new
pool.post { seen << Dexpace::Instrumentation::Diagnostics.capture }
seen.pop # => {"trace.id": "abc123"}
Fiber[:"trace.id"] = nil
pool.post { seen << Dexpace::Instrumentation::Diagnostics.capture }
seen.pop # => {}The context is captured at each #post, on the caller's fiber — per submission, never at construction
(ASYNC-10) — and installed on the worker for the block's duration through phase 5b's
Diagnostics.with, restored in an ensure whether the block returned or raised (ASYNC-9). Two clears
the design measured to be necessary make that install a replacement rather than a merge: a worker
clears the storage it inherited from the fiber that called Pool.build once at thread start, and clears
its storage again after every task, so a key the pool's builder held or a key one caller's task wrote
never sits underneath the next caller's snapshot (P8-20). An absent context captures as {} and
installs as a clear (ASYNC-11). The snapshot's values are the caller's own objects, shared and not
copied: keep what you put into Fiber[] immutable, or synchronise your own access to it.
The same floor holds on the pool's second thread, the timer behind #delay — ASYNC-8 names
callbacks beside work, and a delay's callbacks run on a thread the caller never sees:
Fiber[:"trace.id"] = "first"
pool.delay(0.01).on_settle { seen << Dexpace::Instrumentation::Diagnostics.capture }
seen.pop(timeout: 5) # => {"trace.id": "first"}
Fiber[:"trace.id"] = "second"
pool.delay(0.01).on_settle { seen << Dexpace::Instrumentation::Diagnostics.capture }
seen.pop(timeout: 5) # => {"trace.id": "second"}
Fiber[:"trace.id"] = nil
pool.delay(0.01).then { |_| seen << Dexpace::Instrumentation::Diagnostics.capture }
seen.pop(timeout: 5) # => {}Each #delay captures its caller's context per entry, the timer thread clears what it inherited from
the first caller who spawned it and clears again after every callback, and a #on_settle or #then
on a delay future runs under the context of the caller who asked for that delay (P8-78, extending
P8-20 to the second carrier). The first delay above spawned the timer under "first"; without the
floor the second and the third callbacks would both have read {"trace.id": "first"}. An entry
#close fails settles on the closing thread under the delay caller's context, and the closing thread's
own context is put back afterwards. The one log event the pool emits on a caller's behalf after the
hop — the ERROR http.instrumentation.hook for a block or a handler that raised (P8-22) — is emitted
under that same installed context, so its trace.id is the posting or scheduling caller's on every
carrier, never empty and never the closer's.
pool.delay(0).settled? # => true
pool.delay(0).value # => true
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
pool.delay(0.05).value(deadline: Dexpace::Clock.deadline_in(5)) # => true
Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 >= 0.05 # => true
Thread.list.map(&:name).grep(/docs/).sort # => ["docs timer", "docs worker 0", "docs worker 1"]
pool.delay(-1) # raises Dexpace::InvalidArgumentError: "duration must not be negative, got -1"
pool.delay(Float::NAN) # raises Dexpace::InvalidArgumentError: "duration must be finite, got NaN"
pool.delay(Complex(1, 1)) # raises Dexpace::InvalidArgumentError: "duration must be a real number, got (1+1i)"
late = pool.delay(60)
late.cancel(:no_longer_needed)
late.cancelled? # => trueASYNC-18's four clauses, each with its mechanism: a negative, non-finite (NaN, an infinity), non-real (a
Complex) or non-Numeric duration raises before any thread exists — a NaN answers false to both
negative? and zero?, and one that reached the timer's deadline-ordered list killed its thread and made
every later #delay raise a bare ArgumentError (P8-77); zero settles the future inline and spawns
nothing; a positive delay is one entry on a deadline-ordered list served by one lazily created
"<name> timer" thread, parked against the nearest deadline and re-parked after every wake; cancelling
the future removes its entry at once and wakes the timer, so no scheduler thread is held for a delay
nobody wants. The future settles with true, as phase 5a's Dexpace::Async.delay does, so the two are
interchangeable — and that one is the zero-thread alternative for a caller under a Fiber.scheduler; it
is unavailable to a pool worker, whose Fiber.scheduler is always nil. "Without blocking a thread"
therefore holds for the caller's thread and every worker and not absolutely: one named timer thread per
pool is parked for the interval (P8-25). A #on_settle on a delay future runs on that timer thread,
not on a worker, under the diagnostic context of the caller who asked for the delay (P8-78, above).
logged = Pool.build(size: 2, name: "logged", logger: Dexpace::Instrumentation::Logger.build(sink: sink))
pending = logged.delay(60)
logged.close # => nil
logged.close # => nil
pending.settled? # => true
pending.value # raises Dexpace::ClosedError: "logged is closed"
sink.events.size # => 1
sink.events.first.slice("event", "dexpace.executor.worker_count", "dexpace.executor.drained")
# => {"event" => "http.instrumentation.shutdown", "dexpace.executor.worker_count" => 2, "dexpace.executor.drained" => true}
grace = Pool.build(size: 1, name: "grace")
done = Thread::Queue.new
grace.delay(0.01).on_settle { grace.close; done << :closed } # a close issued ON the timer thread
done.pop(timeout: 5) # => :closed
grace.closed? # => true#close is Dexpace::Closeable's latch — a boolean flipped under a mutex held across the flip only —
and the winner runs the release in this order: close the submission queue (new posts raise
Dexpace::ClosedError; queued work still drains, and a task in flight finishes rather than being
interrupted, ASYNC-16), stop the timer and fail every outstanding delay with Dexpace::ClosedError,
wait for every worker's exit sentinel and the timer within ONE shutdown_timeout budget, then emit
Events::INSTRUMENTATION_SHUTDOWN at INFO exactly once inside Instrumentation.contain — the lifecycle
event phase 2 postponed until something owned an executor (SEAM-25). #close returns nil for every
closeable in this SDK, so whether the drain completed rides on the event's dexpace.executor.drained
field; a spent budget reports false and leaves the stuck worker to finish on its own. There is no
cancellation: keyword on #close (P8-24): Dexpace.close_quietly calls #close with no arguments,
and the bounded budget is what keeps a caller closing inside a cancelled scope from being parked. A
close issued from the pool's own threads completes too (P8-76): from a delay's settlement handler, as
above, the timer thread is not joined by itself and exits once the handler returns; from inside a posted
task, the worker running it counts as drained and finishes its task — and whatever the closed queue
still held — after #close has returned. Neither waits out the budget or raises.
require "dexpace/transport/net_http"
require "dexpace/conformance"
server = Dexpace::Conformance::WireServer.start(Dexpace::Conformance::Scripts.fixed("hello from the wire"))
adapter = Dexpace::Transport::NetHTTP.build(timeout: 5)
wire = Dexpace::Transport.async_over(adapter, executor: pool)
response = wire.call(req("http://127.0.0.1:#{server.port}/"), EMPTY, NONE).value(deadline: Dexpace::Clock.deadline_in(10))
response.status.code # => 200
response.body_string # => "hello from the wire"
server.requests.first.request_line # => "GET / HTTP/1.1"
response.close
server.closeThe charter's convergence point 2, and the first place the async path touches a socket: phase 8a's
synchronous adapter on a worker of this pool, its pump-backed response delivered through the pivot,
the options object and the token threaded through unchanged. The composed proof is this gem's
test/dexpace/async/thread/composed_transport_test.rb, and nothing under its lib/ names either sibling.
paginator = Dexpace::Page::AsyncPaginator.build(
transport: Dexpace::Transport.async_over(fake, executor: pool), # a transport answering a Future
template: req("https://example.test/items"), strategy: strategy, executor: pool,
)
items = Thread::Queue.new
paginator.walk(->(item) { items << item }).value(deadline: Dexpace::Clock.deadline_in(5)) # => 2 (pages)
Array.new(3) { items.pop } # => [1, 2, 3]
pool.closePhase 7c's executor: mode posts every dispatch, the first included, through #post, so the walk —
consumer included — runs on the pool's workers and the pool is what Dexpace::Page::_Executor was
declared for.
- No default pool, no
.instance, no module-level.post:SEAM-18forbids a shared global pool because blocking work would starve it, and a convenience constructor would be one by another name. - No cancellation hook of the pool's own (
R10): the pool posts an opaque block and cannot see the token; shortening a blocked read by closing the socket under it is the transport's, which is 8a's. - No interrupt (
ASYNC-3,ASYNC-4,PIPE-33's last clause): design §10.5's trade, carried and not re-opened;docs/first-release.mdnames the reopening condition. - No
cancellation:on#close(P8-24), noon_saturation:policy and no growth: each would be anNFR-4-locked surface with no requirement behind it. - No second future, completer, token or bridge: every one is phase 2's, and
#delayreturnsDexpace::Async::Future. - No accessor over the threads or the queue:
#size,#queue_limitand#namereturn the values the caller passed, so no constant outsideDexpace::appears in any public signature (NFR-11).