Module: ClaudeAgentSDK::FiberBoundary
- Defined in:
- lib/claude_agent_sdk/fiber_boundary.rb
Overview
Internal. Consumers of the SDK should never need this directly.
The SDK depends on async, which installs a Fiber scheduler whenever an
Async { } block is active. That scheduler multiplexes fibers onto a
single OS thread and intercepts IO so blocking calls yield to siblings.
Most mature Ruby libraries are thread-safe but not fiber-safe: they key
state (checked-out DB connections, per-thread caches, request stores)
on Thread.current. When the scheduler interleaves two fibers on one
thread, those fibers share one state slot — and interleaved IO on a
shared connection silently corrupts wire protocols. This bites every
DB driver keyed by thread (pg, mysql2, sqlite3), ActiveRecord's
connection pool, and any HTTP/cache client pooled per-thread.
The SDK invokes user-supplied callbacks (tool handlers, hooks,
permission callbacks, message blocks, observer methods) from inside
its reactor. FiberBoundary.invoke hops those calls to a plain
Ruby thread so user code runs on a fiber-scheduler-free thread and
inherits the same thread-keyed state assumptions the rest of the
user's app makes.
No-op when no scheduler is active, so it's cheap to use unconditionally.
OPT-IN INLINE MODE: hosts that are already fiber-isolated (e.g. Rails
apps with IsolatedExecutionState.isolation_level = :fiber running
solid_queue fiber workers) can pass scheduling: :inline to run
callbacks in place on the reactor fiber instead of hopping. This is the
same code path as the no-scheduler case — semantically the already-
shipped synchronous path, and Python SDK parity (async callbacks run
natively on the event loop there). The mode is plumbed per-call from
ClaudeAgentOptions#callback_scheduling — never a thread-local (the
reactor thread is shared by many fibers, so a set/reset window across
suspension points would leak across sessions). The one path a call
argument cannot cross (SDK-MCP dispatch through the mcp gem) carries
it via a scoped, invalidatable fiber-storage entry instead — see
DISPATCH_KEY / CallbackDispatchScope below.
A timeout: forces the thread hop — Thread#join(timeout) is a hard
bound that can abandon a wedged call, which cooperative with_timeout
cancellation cannot guarantee — unless the caller passes
scheduling: :inline inside a reactor, where the bound becomes a
cooperative with_timeout cancellation instead (issue #47 phase 3:
fiber-native SessionStore adapters that declare
callback_scheduling -> :inline). The store-adapter call sites
(TranscriptMirrorBatcher, SessionResume) default to the thread hop so
an undeclared (possibly scheduler-opaque) adapter can never stall the
reactor; a declared-inline adapter accepts that a scheduler-opaque
blocking call would stall it AND escape the cooperative deadline.
Outside a reactor the hard bound applies even to inline-declared
adapters — the timeout guarantee is never lost.
The thread hop severs break/return/next from the surrounding method,
so SDK loops yielding user callbacks must keep loop control outside the
invoked block (see Client#receive_response); user-initiated break is
bridged back to the calling fiber via .invoke_iteration.
Deliberate carve-out: the STREAMING-INPUT enumerable is the one user-code path iterated ON the reactor (Query#stream_input), matching Python where async input generators run on the event loop. Enumerator#next is fiber-based and cannot be pulled across threads, and a whole-iteration thread bridge would break Async-native producers (Async::Queue#dequeue etc.). Thread::Queue#pop / sleep / socket IO inside the enumerator are scheduler-aware and park only the stream task; CPU-bound or scheduler-opaque work must be moved by the user (a producer Thread feeding a Thread::Queue, or FiberBoundary.invoke inside the enumerator).
Defined Under Namespace
Classes: Break, CallbackDispatchScope, InlineCancellation, JoinTimeout
Constant Summary collapse
- DISPATCH_KEY =
This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.
Fiber-storage key carrying the dispatching session's callback dispatch pair (scheduling mode + wrapper) across the SDK-MCP dispatch path (Query -> MCP::Server -> dynamic tool class). Fiber storage is per-fiber, so concurrent sessions with different modes sharing one SdkMcpServer instance cannot cross-contaminate — unlike mutating the shared server (last-writer-wins, persists past close) or a thread-local (the reactor thread is shared by many fibers).
:claude_agent_sdk_callback_dispatch
Class Method Summary collapse
-
.invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block) ⇒ Object
Run the given block on a plain thread when a Fiber scheduler is active.
-
.invoke_iteration(block, *args, scheduling: :thread, wrapper: nil) ⇒ Object
Invoke a user-supplied iteration block across the boundary.
-
.with_cooperative_timeout(task, timeout, on_timeout:, &block) ⇒ Object
private
Run
blockontaskbounded by a cooperativetimeout: the deadline is delivered INSIDE user code as an InlineCancellation at the block's next suspension point (ensure blocks run; a callback's ordinaryrescue StandardErrorcannot swallow it), then translated into the exception built byon_timeout(a zero-arg callable) once control has returned from user code — each call site keeps its own outward contract (JoinTimeout on the store-adapter path in .invoke, Async::TimeoutError on the inline hook path in Query).
Class Method Details
.invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block) ⇒ Object
Run the given block on a plain thread when a Fiber scheduler is active. Returns the block's value. Exceptions propagate to the caller.
With timeout (seconds) the thread hop happens regardless of a
scheduler being active, so the bound is enforced in plain synchronous
code too; JoinTimeout is raised when it expires. Exception: with
scheduling: :inline INSIDE an Async task, the block instead runs in
place under a cooperative with_timeout — the deadline is delivered
as InlineCancellation at the block's next suspension point (its
ensure blocks run; not swallowable by rescue StandardError) and
surfaces as the same JoinTimeout, so callers need no changes. When
inline is requested but no Async task is present, the hard
thread-hop bound applies — the timeout guarantee is never lost.
With scheduling: :inline (and no timeout) the block runs in place on
the current fiber, scheduler or not. The caller opts in via
ClaudeAgentOptions#callback_scheduling.
With wrapper (a callable, from ClaudeAgentOptions#callback_wrapper)
the executed body becomes wrapper.call(block) — composed BEFORE the
thread hop, so the wrapper runs on the same execution context as the
callback: on the worker thread in :thread mode (the whole point —
Rails.application.executor.wrap must run on the thread that touches
ActiveRecord), in place on the reactor fiber in :inline mode. The
wrapper must call its argument and return its value; exceptions from
the callback propagate through it unchanged, and exceptions raised by
the wrapper itself are treated exactly like callback exceptions. The
wrapper must not swallow exceptions and must return
the invocation's value — a user's break in a message block reaches
the wrapper as a clean return (invoke_iteration translates it BEFORE
the wrapper sees it), never as an exception a rescue/report wrapper
could falsely flag. In inline/no-scheduler mode break unwinds natively through the
wrapper's stack, so ensure-based wrappers (executor.wrap) are safe.
On timeout-bounded calls (the store-adapter paths:
TranscriptMirrorBatcher, SessionResume) the wrapper composes INSIDE
the bound: on the worker thread within the Thread#join deadline in
:thread mode (so executor.wrap covers an AR-backed store adapter —
connections check back in when the call ends; on a timeout the
abandoned worker still runs the wrapper's ensure whenever the wedged
call eventually finishes), and inside the cooperative with_timeout
in inline mode (the cancellation passes through the wrapper
un-swallowed — InlineCancellation is not a StandardError — and the
wrapper's ensure runs at cancellation).
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 |
# File 'lib/claude_agent_sdk/fiber_boundary.rb', line 188 def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block) body = wrapper ? -> { wrapper.call(block) } : block return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler) # Cooperative timeout for inline-declared store adapters: in place on # the reactor fiber, cancelled at the next suspension point (delivery # and translation mechanics live in .with_cooperative_timeout). The # wrapper is already composed into `body`, so it sits inside the # timeout scope. if timeout && scheduling == :inline && (task = Async::Task.current?) expired = -> { JoinTimeout.new("timed out after #{timeout}s") } return with_cooperative_timeout(task, timeout, on_timeout: expired) { body.call } end thread = Thread.new(&body) thread.report_on_exception = false return thread.value if timeout.nil? raise JoinTimeout, "timed out after #{timeout}s" unless thread.join(timeout) thread.value end |
.invoke_iteration(block, *args, scheduling: :thread, wrapper: nil) ⇒ Object
Invoke a user-supplied iteration block across the boundary. The thread
hop severs break from the surrounding loop, surfacing as
LocalJumpError(reason: :break) on the worker thread; translate it into
a Break sentinel so the SDK loop can break on the calling fiber.
Returns Break when the user broke, nil when the block completed.
Without a scheduler (or with scheduling: :inline) the block runs in
place and break unwinds natively, never reaching the translation.
The LocalJumpError -> Break translation lives INSIDE the invocation
handed to the wrapper: a user's break is normal loop control, not
an error, so a conforming rescue/report/re-raise wrapper must observe
a clean return (the Break sentinel as the invocation's value), never
a LocalJumpError it could falsely report or swallow. In inline /
no-scheduler mode break unwinds natively through the wrapper's
stack instead (ensure-based wrappers still run their cleanup).
254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/claude_agent_sdk/fiber_boundary.rb', line 254 def invoke_iteration(block, *args, scheduling: :thread, wrapper: nil) invocation = lambda do block.call(*args) nil rescue LocalJumpError => e raise unless e.reason == :break Break.new(e.exit_value) end body = wrapper ? -> { wrapper.call(invocation) } : invocation invoke(scheduling: scheduling) { body.call } end |
.with_cooperative_timeout(task, timeout, on_timeout:, &block) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Run block on task bounded by a cooperative timeout: the deadline
is delivered INSIDE user code as an InlineCancellation at the block's
next suspension point (ensure blocks run; a callback's ordinary
rescue StandardError cannot swallow it), then translated into the
exception built by on_timeout (a zero-arg callable) once control has
returned from user code — each call site keeps its own outward
contract (JoinTimeout on the store-adapter path in .invoke,
Async::TimeoutError on the inline hook path in Query).
The cancellation class is a fresh per-invocation subclass: rescuing the shared base would also catch an OUTER timeout's cancellation delivered while this call is suspended (nested with_timeout — e.g. an inline store call nested inside a timed inline hook), mis-attributing the outer deadline to this call and letting execution continue past it. An outer cancellation is a different subclass, so it propagates through untouched.
Wrapper composition is the caller's choice — block runs verbatim
inside the timeout scope (.invoke composes the callback wrapper into
its body beforehand; the hook path composes it inside the block).
231 232 233 234 235 236 237 238 |
# File 'lib/claude_agent_sdk/fiber_boundary.rb', line 231 def with_cooperative_timeout(task, timeout, on_timeout:, &block) cancellation = Class.new(InlineCancellation) begin task.with_timeout(timeout, cancellation, &block) rescue cancellation raise on_timeout.call end end |