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 SCHEDULING_KEY / SchedulingScope below.

A timeout: always forces the thread hop regardless of scheduling — Thread#join(timeout) is a hard bound that can abandon a wedged call, which cooperative with_timeout cancellation cannot guarantee. The store-adapter call sites (TranscriptMirrorBatcher, SessionResume) rely on this to never stall the reactor on a wedged adapter.

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, InlineCancellation, JoinTimeout, SchedulingScope

Constant Summary collapse

SCHEDULING_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 scheduling mode 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_scheduling

Class Method Summary collapse

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 unconditionally — even without a scheduler or with scheduling: :inline — so the bound is enforced in plain synchronous code too; JoinTimeout is raised when it expires.

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.

The timeout path deliberately ignores the wrapper: it belongs to the store-adapter carve-out (TranscriptMirrorBatcher, SessionResume), which never carries user callbacks.

Raises:



164
165
166
167
168
169
170
171
172
173
174
# File 'lib/claude_agent_sdk/fiber_boundary.rb', line 164

def invoke(timeout: nil, scheduling: :thread, wrapper: nil, &block)
  body = wrapper && timeout.nil? ? -> { wrapper.call(block) } : block
  return body.call if timeout.nil? && (scheduling == :inline || !Fiber.scheduler)

  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).



190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/claude_agent_sdk/fiber_boundary.rb', line 190

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