claude-agent-sdk 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +110 -0
  4. data/README.md +43 -31
  5. data/docs/cli-installer.md +26 -4
  6. data/docs/client.md +40 -11
  7. data/docs/configuration.md +206 -1
  8. data/docs/errors.md +32 -2
  9. data/docs/hooks-and-permissions.md +30 -10
  10. data/docs/mcp-servers.md +30 -9
  11. data/docs/observability.md +61 -10
  12. data/docs/options.md +232 -0
  13. data/docs/rails.md +263 -18
  14. data/docs/sessions.md +40 -12
  15. data/docs/subagents.md +1 -1
  16. data/docs/types.md +100 -11
  17. data/lib/claude_agent_sdk/cli_installer.rb +140 -19
  18. data/lib/claude_agent_sdk/command_builder.rb +84 -27
  19. data/lib/claude_agent_sdk/fiber_boundary.rb +45 -2
  20. data/lib/claude_agent_sdk/instrumentation/otel.rb +90 -28
  21. data/lib/claude_agent_sdk/query.rb +547 -132
  22. data/lib/claude_agent_sdk/railtie.rb +27 -2
  23. data/lib/claude_agent_sdk/sdk_mcp_server.rb +78 -26
  24. data/lib/claude_agent_sdk/session_mutations.rb +112 -92
  25. data/lib/claude_agent_sdk/session_resume.rb +356 -39
  26. data/lib/claude_agent_sdk/session_store.rb +31 -2
  27. data/lib/claude_agent_sdk/sessions.rb +720 -138
  28. data/lib/claude_agent_sdk/subprocess_cli_transport.rb +252 -29
  29. data/lib/claude_agent_sdk/testing/session_store_conformance.rb +18 -7
  30. data/lib/claude_agent_sdk/transcript_mirror_batcher.rb +45 -37
  31. data/lib/claude_agent_sdk/transport.rb +28 -12
  32. data/lib/claude_agent_sdk/types/attributes.rb +9 -0
  33. data/lib/claude_agent_sdk/types/base.rb +85 -15
  34. data/lib/claude_agent_sdk/types/hooks.rb +73 -0
  35. data/lib/claude_agent_sdk/types/mcp.rb +37 -1
  36. data/lib/claude_agent_sdk/types/messages.rb +7 -1
  37. data/lib/claude_agent_sdk/types/option_values.rb +186 -4
  38. data/lib/claude_agent_sdk/types/options.rb +104 -17
  39. data/lib/claude_agent_sdk/types/permissions.rb +18 -9
  40. data/lib/claude_agent_sdk/version.rb +1 -1
  41. data/lib/claude_agent_sdk.rb +111 -53
  42. data/lib/generators/claude_agent_sdk/install/templates/claude_agent_sdk.rb.tt +6 -0
  43. data/sig/claude_agent_sdk/types/hooks.rbs +6 -3
  44. data/sig/claude_agent_sdk/types/option_values.rbs +23 -6
  45. data/sig/claude_agent_sdk/types/options.rbs +20 -7
  46. data/sig/claude_agent_sdk/types/permissions.rbs +4 -2
  47. metadata +6 -4
@@ -49,14 +49,103 @@ module ClaudeAgentSDK
49
49
  # status, or it will hang the query (see #track_task_lifecycle).
50
50
  DEFERRING_TASK_TYPES = %w[local_agent local_workflow].freeze
51
51
 
52
- # Waiter for control responses awaited OFF the reactor — i.e. a control
53
- # method called from inside a hook/can_use_tool/SDK-MCP callback, which
54
- # runs on a FiberBoundary worker thread (Python supports this reentrancy
55
- # natively: callbacks are event-loop tasks and anyio.Event is
56
- # level-triggered). Duck-types Async::Condition#signal for the read
57
- # loop's signal sites; the unconditional token push makes it
58
- # level-triggered, closing the check-then-wait gap that an
59
- # edge-triggered Condition would lose across threads.
52
+ # The CLI's own wait for background work once stdin is closed; the SDK
53
+ # bounds its wait for the CLI's "idle" by the same value
54
+ # (#arm_run_end_ceiling, Python #1279).
55
+ RUN_END_CEILING_ENV_VAR = 'CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS'
56
+ DEFAULT_RUN_END_CEILING_MS = 600_000
57
+ # The longest ceiling honored (~24.8 days), as in the TypeScript and
58
+ # Python SDKs, whose timers cannot run longer.
59
+ MAX_RUN_END_CEILING_MS = (2**31) - 1
60
+ # Frame types that mark a main-thread turn under way (when they carry no
61
+ # parent_tool_use_id), and the states that do not re-arm the ceiling.
62
+ TURN_FRAME_TYPES = %w[assistant stream_event].freeze
63
+ NON_RUNNING_SESSION_STATES = %w[idle requires_action].freeze
64
+
65
+ # Read CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS from where the CLI gets it:
66
+ # +options_env+ (ClaudeAgentOptions#env) overrides the inherited
67
+ # environment, as it does for the subprocess, and a key present with a nil
68
+ # value is unset in the child, so the CLI falls back to its default. 0
69
+ # means no limit. Only plain non-negative integers are read; anything else
70
+ # falls back to the CLI's default of 10 minutes, including spellings the
71
+ # CLI itself also reads, such as 1e6 (as in the Python SDK).
72
+ def self.run_end_ceiling_ms(options_env)
73
+ env = (options_env || {}).transform_keys(&:to_s)
74
+ raw = env.key?(RUN_END_CEILING_ENV_VAR) ? env[RUN_END_CEILING_ENV_VAR] : ENV.fetch(RUN_END_CEILING_ENV_VAR, nil)
75
+ digits = raw.to_s.strip
76
+ digits.match?(/\A\d+\z/) ? Integer(digits, 10) : DEFAULT_RUN_END_CEILING_MS
77
+ end
78
+
79
+ # One run's end, set at most once (the Python SDK's per-run anyio.Event).
80
+ # A waiter holds the object it started waiting on, so a run that ends and
81
+ # is then reopened (#reopen_run swaps in a fresh RunEnd) still releases the
82
+ # waiters the ended run woke, while later waits wait for the new run.
83
+ # Reactor-only, like every caller.
84
+ class RunEnd
85
+ def initialize
86
+ @ended = false
87
+ @condition = Async::Condition.new
88
+ end
89
+
90
+ def ended?
91
+ @ended
92
+ end
93
+
94
+ def end!
95
+ return if @ended
96
+
97
+ @ended = true
98
+ @condition.signal
99
+ end
100
+
101
+ def wait
102
+ @condition.wait until @ended
103
+ end
104
+ end
105
+
106
+ # Apply ClaudeAgentOptions#verbatim_prompts to one outgoing user message
107
+ # (Python #1269's stamp_user_message). Off: returns +message+ unchanged.
108
+ # On: returns a new Hash with `client_composed: true`, dropping any
109
+ # caller-supplied value under either key spelling first (JSON.generate
110
+ # would otherwise emit the key twice). A pre-serialized JSONL String — a
111
+ # Ruby-only input shape — is parsed, marked and handed back as a Hash; one
112
+ # that is not a single JSON object raises ArgumentError, because sending it
113
+ # unmarked would silently defeat the option.
114
+ def self.stamp_user_message(message, verbatim_prompts)
115
+ return message unless verbatim_prompts
116
+
117
+ hash = message.is_a?(Hash) ? message : parse_streamed_message(message.to_s)
118
+ hash.reject { |key, _| key.to_s == 'client_composed' }.merge(client_composed: true)
119
+ end
120
+
121
+ # The serialized line for one outgoing user message, stamped per
122
+ # verbatim_prompts. Hashes are JSON-generated; other items pass through
123
+ # as their String form unless they must be stamped.
124
+ def self.serialize_user_message(message, verbatim_prompts)
125
+ stamped = stamp_user_message(message, verbatim_prompts)
126
+ stamped.is_a?(Hash) ? JSON.generate(stamped) : stamped.to_s
127
+ end
128
+
129
+ def self.parse_streamed_message(string)
130
+ parsed = JSON.parse(string)
131
+ return parsed if parsed.is_a?(Hash)
132
+
133
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (got #{parsed.class})"
134
+ rescue JSON::ParserError => e
135
+ raise ArgumentError, "verbatim_prompts: a streamed message String must be one JSON object (#{e.message})"
136
+ end
137
+ private_class_method :parse_streamed_message
138
+
139
+ # Waiter for control responses awaited OFF the reactor that owns the
140
+ # Query — a control method called from inside a hook/can_use_tool/SDK-MCP
141
+ # callback, which runs on a FiberBoundary worker thread (Python supports
142
+ # this reentrancy natively: callbacks are event-loop tasks and
143
+ # anyio.Event is level-triggered), from any other plain thread, or from a
144
+ # fiber on another thread's reactor (see #send_control_request for the
145
+ # choice). Duck-types Async::Condition#signal for the read loop's signal
146
+ # sites; the unconditional token push makes it level-triggered, closing
147
+ # the check-then-wait gap that an edge-triggered Condition would lose
148
+ # across threads.
60
149
  class ThreadWaiter
61
150
  def initialize
62
151
  @queue = ::Queue.new
@@ -74,7 +163,8 @@ module ClaudeAgentSDK
74
163
  def initialize(transport:, is_streaming_mode:, can_use_tool: nil, hooks: nil, sdk_mcp_servers: nil, agents: nil, # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- initializes every control-protocol concern in one place
75
164
  exclude_dynamic_sections: nil, system_prompt_snapshot: nil, skills: nil,
76
165
  forward_subagent_text: false, agent_progress_summaries: nil,
77
- callback_scheduling: :thread, callback_wrapper: nil)
166
+ callback_scheduling: :thread, callback_wrapper: nil, verbatim_prompts: false,
167
+ run_end_ceiling_ms: DEFAULT_RUN_END_CEILING_MS)
78
168
  @transport = transport
79
169
  @is_streaming_mode = is_streaming_mode
80
170
  @can_use_tool = can_use_tool
@@ -88,6 +178,8 @@ module ClaudeAgentSDK
88
178
  @skills = skills
89
179
  @forward_subagent_text = forward_subagent_text
90
180
  @agent_progress_summaries = agent_progress_summaries
181
+ @verbatim_prompts = verbatim_prompts
182
+ @run_end_ceiling_ms = run_end_ceiling_ms
91
183
 
92
184
  # Control protocol state
93
185
  @pending_control_responses = {}
@@ -103,10 +195,32 @@ module ClaudeAgentSDK
103
195
 
104
196
  # Message stream
105
197
  @message_queue = Async::Queue.new
106
- # Set when a run-ending result arrives (a result frame with no tasks in
107
- # flight) so the stdin-closing waiter can wake. Named for history — it
108
- # once tracked the literal first result.
109
- @first_result_received = false
198
+ # Set once #receive_messages has consumed the read loop's end sentinel.
199
+ @stream_ended = false
200
+ # Ends when the run is over, so the stdin-closing waiter can wake; see
201
+ # #read_messages and @inflight_tasks below (Python #1088, #1190/#1279).
202
+ # Work the CLI takes up after the run ended swaps in a fresh one
203
+ # (#reopen_run).
204
+ @run_end = RunEnd.new
205
+ @result_received = false
206
+ # The CLI's latest session_state_changed state, or nil while it sends
207
+ # none (a CLI too old to honor CLAUDE_CODE_SDK_READS_SESSION_STATE). A
208
+ # CLI that reports state stays "running" while a background agent is
209
+ # live or its completion is still to be handled, and reports "idle" once
210
+ # no further turn is owed.
211
+ @session_state = nil
212
+ # Ends the run if no new turn starts within the ceiling after a result
213
+ # (#arm_run_end_ceiling). The generation tells a sleeper that woke after
214
+ # it was cleared or re-armed to stand down.
215
+ @run_end_ceiling_task = nil
216
+ @run_end_ceiling_generation = 0
217
+ # A main-thread turn is under way (its assistant/stream_event frames
218
+ # have started and its result has not arrived): the ceiling counts only
219
+ # the wait between turns, so it is not armed meanwhile.
220
+ @turn_in_progress = false
221
+ # Set once stdin is closed or the reader is gone: the run then stays
222
+ # ended, since nothing can wait on a reopened one.
223
+ @run_final = false
110
224
  # Task IDs of started-but-not-finished deferring tasks. A result frame
111
225
  # only ends one turn, not the run: a background task keeps running past
112
226
  # it and still needs stdin for hook/SDK-MCP control responses (Python
@@ -119,7 +233,6 @@ module ClaudeAgentSDK
119
233
  # reported. Mirrors the TypeScript SDK's `lastErrorResultText`
120
234
  # (Query.ts), but keeps the whole payload rather than just the text.
121
235
  @last_error_result = nil
122
- @first_result_condition = Async::Condition.new
123
236
  @task = nil
124
237
  @child_tasks = []
125
238
  @initialized = false
@@ -178,6 +291,10 @@ module ClaudeAgentSDK
178
291
  agents_dict = nil
179
292
  if @agents
180
293
  agents_dict = @agents.transform_values do |agent_def|
294
+ # A Hash stands for the AgentDefinition with the same attributes
295
+ # (the options signature allows either). Built through .new: the
296
+ # user wrote it, so a misspelled key raises as on the typed class.
297
+ agent_def = AgentDefinition.new(agent_def) if agent_def.is_a?(Hash)
181
298
  {
182
299
  description: agent_def.description,
183
300
  prompt: agent_def.prompt,
@@ -256,18 +373,15 @@ module ClaudeAgentSDK
256
373
  # Reactor-side agent for #close calls arriving from foreign threads
257
374
  # (FiberBoundary callbacks, plain user threads): Async::Task#stop needs
258
375
  # the owning thread's Fiber.scheduler, so the off-thread caller hands the
259
- # whole close over and waits. Transient: must never keep the reactor
260
- # alive, and is stopped automatically when the parent task finishes.
261
- # One-shot: after serving a close it is done; a reactor-side close wakes
262
- # it via @close_requests.close (pop -> nil) so it exits without serving.
376
+ # whole close over and waits. Transient: while it waits for a request it
377
+ # must never keep the reactor alive, and it is stopped automatically
378
+ # once the reactor has nothing else to run. One-shot: it passes the
379
+ # first request on to a task of its own (#serve_marshalled_close) and
380
+ # is done; a reactor-side close wakes it via @close_requests.close
381
+ # (pop -> nil) so it exits without serving.
263
382
  @close_watcher = parent.async(transient: true, &FiberBoundary.capture_otel_context do
264
- if (reply = @close_requests.pop)
265
- begin
266
- close
267
- ensure
268
- reply << true
269
- end
270
- end
383
+ reply = @close_requests.pop
384
+ serve_marshalled_close(reply) if reply
271
385
  end)
272
386
  end
273
387
 
@@ -376,22 +490,26 @@ module ClaudeAgentSDK
376
490
  else
377
491
  # Track task lifecycle frames so results can tell "one turn ended"
378
492
  # apart from "the run is done" (Python #1088/#1103).
379
- track_task_lifecycle(message) if message[:type] == 'system'
493
+ if msg_type == 'system'
494
+ had_tasks_in_flight = !@inflight_tasks.empty?
495
+ track_task_lifecycle(message)
496
+ # The ceiling left the last tracked agent alone; the wait between
497
+ # turns starts over now that it settled.
498
+ rearm_run_end_ceiling_between_turns if had_tasks_in_flight && @inflight_tasks.empty?
499
+ if message[:subtype] == 'session_state_changed'
500
+ on_session_state(message[:state])
501
+ # Frames the CLI sent only because the transport asked for them
502
+ # (CLAUDE_CODE_SDK_READS_SESSION_STATE); the caller did not opt
503
+ # in, so they never reach the stream, observers or the parser.
504
+ next if message[:sdk_host_only] == true
505
+ end
506
+ end
380
507
 
381
- if message[:type] == 'result'
508
+ if msg_type == 'result'
382
509
  # Flush the mirror before signaling/yielding the result so a
383
510
  # consumer observing the result sees an up-to-date store for the turn.
384
511
  flush_transcript_mirror
385
- # A result with tasks still in flight ends one turn, not the run:
386
- # the tasks may still need hook/SDK-MCP control responses over
387
- # stdin, and closing it now silently disables hooks and fails
388
- # SDK-MCP calls with "Stream closed". Each deferring task's
389
- # completion wakes the parent for a follow-up turn, so a later
390
- # result arrives with no tasks in flight and closes stdin then.
391
- if @inflight_tasks.empty? && !@first_result_received
392
- @first_result_received = true
393
- @first_result_condition.signal
394
- end
512
+ on_result
395
513
  @last_error_result = message[:is_error] ? message : nil
396
514
  elsif !(msg_type == 'system' && message[:subtype] == 'session_state_changed')
397
515
  # Anything other than the post-turn session_state_changed marker
@@ -399,6 +517,14 @@ module ClaudeAgentSDK
399
517
  # crash, not the expected exit from a prior error result. Mirrors
400
518
  # the Python/TypeScript SDK reset logic.
401
519
  @last_error_result = nil
520
+ # A main-thread turn is under way, so the ceiling stops (it counts
521
+ # only the wait between turns, as the CLI's does) and the run
522
+ # reopens even if the ceiling ended it while no state changed.
523
+ if TURN_FRAME_TYPES.include?(msg_type) && message[:parent_tool_use_id].nil?
524
+ @turn_in_progress = true
525
+ reopen_run
526
+ clear_run_end_ceiling
527
+ end
402
528
  end
403
529
  # Regular SDK messages go to the queue
404
530
  @message_queue.enqueue(message)
@@ -447,10 +573,11 @@ module ClaudeAgentSDK
447
573
  begin
448
574
  flush_transcript_mirror
449
575
  ensure
450
- unless @first_result_received
451
- @first_result_received = true
452
- @first_result_condition.signal
453
- end
576
+ # Unblock the stdin-closing waiter so it doesn't stall on early exit;
577
+ # with the reader gone the run stays ended. Also stops a pending
578
+ # ceiling sleeper, which would otherwise keep the reactor alive.
579
+ @run_final = true
580
+ end_run
454
581
  # Always signal end of stream
455
582
  @message_queue.enqueue({ type: 'end' })
456
583
  end
@@ -500,6 +627,138 @@ module ClaudeAgentSDK
500
627
  end
501
628
  end
502
629
 
630
+ # A result ends a turn, not necessarily the run: a background agent that
631
+ # finished just before it still wakes the session for another turn, whose
632
+ # hook, permission and SDK MCP requests need stdin (Python #1190/#1279). A
633
+ # CLI that reports session state stays "running" while such a turn is
634
+ # owed, so wait for "idle" (some hosts send it just before the result).
635
+ # Without state events the result is all there is to go on.
636
+ def on_result
637
+ @result_received = true
638
+ @turn_in_progress = false
639
+ if @session_state.nil? || @session_state == 'idle' || !bidirectional_needs?
640
+ maybe_end_run
641
+ elsif @session_state != 'requires_action'
642
+ # While the SDK is still answering a request the ceiling waits for
643
+ # the "running" that follows.
644
+ arm_run_end_ceiling
645
+ end
646
+ end
647
+
648
+ # Track the CLI's session_state_changed state (Python #1279).
649
+ def on_session_state(state)
650
+ @session_state = state
651
+ if state == 'idle'
652
+ maybe_end_run if @result_received
653
+ return
654
+ end
655
+ # Work the CLI took up after the run ended (a finished background task
656
+ # woke it) reopens the run until the next "idle".
657
+ reopen_run
658
+ if state == 'requires_action'
659
+ # The host is answering a request; stdin must outlast it.
660
+ clear_run_end_ceiling
661
+ else
662
+ rearm_run_end_ceiling_between_turns
663
+ end
664
+ end
665
+
666
+ # End the run unless a tracked background task is still in flight: such
667
+ # a task may still need hook/SDK-MCP control responses over stdin
668
+ # (Python #1088), and its completion wakes the parent for a follow-up
669
+ # turn whose result (or "idle") ends the run then. A CLI that reports
670
+ # session state never reports "idle" with an agent still live, so this
671
+ # matters for CLIs that report "idle" at every turn end or not at all.
672
+ def maybe_end_run
673
+ end_run if @inflight_tasks.empty?
674
+ end
675
+
676
+ # The run is over: wake the stdin-closing waiter. Idempotent.
677
+ #
678
+ # The run is marked ended BEFORE the ceiling is cleared: stopping the
679
+ # sleeper task yields to whatever else is ready, and a #stream_input task
680
+ # that writes its next message in that gap must find the run already
681
+ # ended, so that #reopen_run gives the message a run of its own. Cleared
682
+ # first, the message joined the run that was about to end, and stdin
683
+ # closed before the message's own run had produced a frame.
684
+ def end_run
685
+ @run_end.end!
686
+ clear_run_end_ceiling
687
+ end
688
+
689
+ # Reopen an ended run for work that started after it ended. A waiter the
690
+ # ended run already woke still closes stdin; this makes a later wait
691
+ # (stream_input's, once its prompts are all written) wait for the new
692
+ # work too. Once stdin is closed, or the reader is gone, the run stays
693
+ # ended.
694
+ def reopen_run
695
+ @run_end = RunEnd.new if @run_end.ended? && !@run_final
696
+ end
697
+
698
+ # End the run anyway once the ceiling passes with no new turn. The CLI's
699
+ # own background-wait ceiling only counts once stdin is closed, so without
700
+ # this, work that never finishes would hold "running", and stdin, open
701
+ # forever. It counts only the wait between turns: restarted at each result
702
+ # and whenever the CLI reports "running" again, cleared by main-thread
703
+ # turn activity and by "requires_action", never armed mid-turn.
704
+ #
705
+ # The sleeper is a child of the read task, NOT a #spawn_task entry: it is re-armed at every
706
+ # result and every "running", and @child_tasks never prunes. The read
707
+ # task's stop cascades to it, and every exit path clears it (#end_run in
708
+ # the read loop's ensure, #wait_for_result_and_end_input's ensure), so a
709
+ # pending sleeper can never keep the enclosing reactor alive.
710
+ def arm_run_end_ceiling
711
+ clear_run_end_ceiling
712
+ # A frame read while close is under way (the result branch flushes the
713
+ # mirror first) must not leave a sleeper behind either.
714
+ return if @run_end_ceiling_ms <= 0 || @run_end.ended? || @run_final || @closed ||
715
+ @turn_in_progress || !bidirectional_needs?
716
+
717
+ generation = @run_end_ceiling_generation
718
+ seconds = [@run_end_ceiling_ms, MAX_RUN_END_CEILING_MS].min / 1000.0
719
+ @run_end_ceiling_task = @task.async do
720
+ sleep seconds
721
+ end_run_at_ceiling(generation)
722
+ end
723
+ end
724
+
725
+ # Restart the ceiling if the run is between turns, past a result, with
726
+ # the CLI still reporting work ("running").
727
+ def rearm_run_end_ceiling_between_turns
728
+ return unless @result_received
729
+ return if @session_state.nil? || NON_RUNNING_SESSION_STATES.include?(@session_state)
730
+
731
+ arm_run_end_ceiling
732
+ end
733
+
734
+ def end_run_at_ceiling(generation)
735
+ # Cleared or re-armed while this sleeper was already waking up.
736
+ return unless generation == @run_end_ceiling_generation
737
+
738
+ # Detach before ending the run: #end_run clears the ceiling, and
739
+ # clearing it must not stop the task running this very method.
740
+ @run_end_ceiling_task = nil
741
+ # A tracked background agent still running may still need stdin for its
742
+ # hook, permission and SDK MCP requests (Python #1088), so it is not cut
743
+ # off; the ceiling starts over once it settles (#read_messages).
744
+ return unless @inflight_tasks.empty?
745
+
746
+ # A control request the SDK is still answering (a slow hook or SDK MCP
747
+ # tool) must be able to write its reply, so the clock starts over rather
748
+ # than closing stdin under it. Ruby-only guard: Python relies on the CLI
749
+ # reporting requires_action for every such request.
750
+ return arm_run_end_ceiling unless @inflight_control_request_tasks.empty?
751
+
752
+ end_run
753
+ end
754
+
755
+ def clear_run_end_ceiling
756
+ @run_end_ceiling_generation += 1
757
+ task = @run_end_ceiling_task
758
+ @run_end_ceiling_task = nil
759
+ task&.stop
760
+ end
761
+
503
762
  # Whether the CLI may still send control requests that need a reply.
504
763
  #
505
764
  # SDK MCP servers, hooks and the can_use_tool permission callback are all
@@ -595,15 +854,42 @@ module ClaudeAgentSDK
595
854
  raise
596
855
  rescue StandardError => e
597
856
  send_control_error(request_id, e.message)
857
+ rescue *FiberBoundary::CALLBACK_FAILURES => e
858
+ # What is left of the list: a callback (or its callback_wrapper)
859
+ # failing outside StandardError — NotImplementedError, LoadError,
860
+ # SystemStackError, SecurityError. No `rescue StandardError` on the
861
+ # way here caught it, not even the one in #handle_sdk_mcp_request that
862
+ # turns a resource or prompt handler's failure into its JSON-RPC
863
+ # error, so the answer an ordinary failure gets is built here.
864
+ # Unanswered, it would also end this task with an exception Async
865
+ # treats as fatal for the whole reactor.
866
+ respond_to_callback_failure(request_id, request_data, e.message)
598
867
  end
599
868
 
600
- # The response an ordinary exception from the callback would have
601
- # produced, with the process-exit exception named by class: an error
602
- # control response for hooks / can_use_tool; for SDK MCP requests an
603
- # in-band isError result (tools/call) or a JSON-RPC internal error
604
- # (resources/read, prompts/get), inside a successful control response.
869
+ # Answers the request, then leaves the process-exit exception to its
870
+ # caller to re-raise: the response an ordinary exception from the
871
+ # callback would have produced, naming the exception by class.
872
+ #
873
+ # The text has the format of FiberBoundary.process_exit_message, built
874
+ # here from the normalized message instead of calling it: that method
875
+ # joins the class name to the raw message, which raises
876
+ # Encoding::CompatibilityError for an encoding that is not
877
+ # ASCII-compatible (UTF-16). Raised in the rescue clause this runs in,
878
+ # that error would leave the request unanswered and replace the exit.
879
+ # +error+ is only read, never changed.
605
880
  def respond_to_process_exit(request_id, request_data, error)
606
- message = FiberBoundary.process_exit_message(error)
881
+ detail = wire_text(error.message)
882
+ message = detail.empty? || detail == error.class.name ? error.class.name : "#{error.class}: #{detail}"
883
+ respond_to_callback_failure(request_id, request_data, message)
884
+ end
885
+
886
+ # The response an ordinary exception from the callback would have
887
+ # produced, with +message+ as its text: an error control response for
888
+ # hooks / can_use_tool; for SDK MCP requests an in-band isError result
889
+ # (tools/call) or a JSON-RPC internal error (resources/read,
890
+ # prompts/get), inside a successful control response.
891
+ def respond_to_callback_failure(request_id, request_data, message)
892
+ message = wire_text(message)
607
893
  mcp_message = request_data[:message] if request_data.is_a?(Hash) && request_data[:subtype] == 'mcp_message'
608
894
  return send_control_error(request_id, message) unless mcp_message.is_a?(Hash)
609
895
 
@@ -620,10 +906,19 @@ module ClaudeAgentSDK
620
906
  response: { mcp_response: mcp_response }
621
907
  }
622
908
  }))
909
+ rescue JSON::GeneratorError
910
+ # Not reachable through the text, which #wire_text made encodable.
911
+ # Kept because an exception leaving this method would take the place
912
+ # of the process exit the caller is about to re-raise.
913
+ send_control_error(request_id, message)
623
914
  rescue CLIConnectionError
624
915
  nil # the CLI is already gone; nothing is waiting for the answer
625
916
  end
626
917
 
918
+ # Called from the rescue clauses of #handle_control_request, where an
919
+ # exception raised while building the answer has no rescue left: the
920
+ # request would stay unanswered. So the text goes through #wire_text, and
921
+ # whatever JSON.generate still rejects is replaced by a fixed one.
627
922
  def send_control_error(request_id, message)
628
923
  error_response = {
629
924
  type: 'control_response',
@@ -631,10 +926,16 @@ module ClaudeAgentSDK
631
926
  subtype: 'error',
632
927
  request_id: request_id,
633
928
  requestId: request_id,
634
- error: message
929
+ error: wire_text(message)
635
930
  }
636
931
  }
637
- writeln(JSON.generate(error_response))
932
+ line = begin
933
+ JSON.generate(error_response)
934
+ rescue JSON::GeneratorError
935
+ error_response[:response][:error] = 'Control request failed; its error message could not be encoded as JSON'
936
+ JSON.generate(error_response)
937
+ end
938
+ writeln(line)
638
939
  rescue CLIConnectionError
639
940
  # EOF/close can invalidate a callback after the peer has gone away.
640
941
  # Only this best-effort reply is discarded; read errors still reach
@@ -642,6 +943,27 @@ module ClaudeAgentSDK
642
943
  nil
643
944
  end
644
945
 
946
+ # Error text as JSON.generate accepts it. An exception message can hold
947
+ # anything: a multibyte character cut by byteslice, the raw bytes of a
948
+ # subprocess or an HTTP body, a driver's own encoding. Valid UTF-8
949
+ # passes through. A UTF-8, BINARY or US-ASCII string is read as UTF-8,
950
+ # with U+FFFD in place of each byte that is not valid there. Any other
951
+ # encoding is transcoded, so valid text in it survives, with U+FFFD for
952
+ # what cannot be converted.
953
+ def wire_text(text)
954
+ text = text.to_s
955
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
956
+
957
+ if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(text.encoding)
958
+ text.dup.force_encoding(Encoding::UTF_8).scrub
959
+ else
960
+ text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
961
+ end
962
+ rescue EncodingError
963
+ # No converter for the declared encoding (a dummy one such as UTF-7).
964
+ text.dup.force_encoding(Encoding::UTF_8).scrub
965
+ end
966
+
645
967
  def handle_permission_request(request_data, request_id: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- permission round-trip: input, callback, result conversion
646
968
  raise 'canUseTool callback is not provided' unless @can_use_tool
647
969
 
@@ -1025,36 +1347,24 @@ module ClaudeAgentSDK
1025
1347
  { mcp_response: mcp_response }
1026
1348
  end
1027
1349
 
1028
- def convert_hook_output_for_cli(hook_output) # rubocop:disable Metrics/CyclomaticComplexity -- one optional field per hook output key
1029
- # Handle typed output objects
1030
- return hook_output.to_h if hook_output.respond_to?(:to_h) && !hook_output.is_a?(Hash)
1031
-
1350
+ # What a hook callback returned, as the object the CLI reads. A typed
1351
+ # output (or anything else with #to_h) contributes its own Hash; a typed
1352
+ # value inside a Hash, such as a *HookSpecificOutput under
1353
+ # hook_specific_output, does the same. The keys of the result are then
1354
+ # normalized, so a Hash written in snake_case or with String keys means
1355
+ # what the typed output means (HookOutputKeys) — and so does the Hash a
1356
+ # typed output carried through as its hook_specific_output.
1357
+ def convert_hook_output_for_cli(hook_output)
1358
+ if hook_output.is_a?(Hash)
1359
+ hook_output = hook_output.transform_values do |value|
1360
+ value.respond_to?(:to_h) && !value.is_a?(Hash) ? value.to_h : value
1361
+ end
1362
+ elsif hook_output.respond_to?(:to_h)
1363
+ hook_output = hook_output.to_h
1364
+ end
1032
1365
  return {} unless hook_output.is_a?(Hash)
1033
1366
 
1034
- # Convert Ruby hash with symbol keys to CLI format
1035
- # Handle special keywords that might be Ruby-safe versions
1036
- converted = {}
1037
- hook_output.each do |key, value|
1038
- converted_key = case key
1039
- when :async_, 'async_' then 'async'
1040
- when :continue_, 'continue_' then 'continue'
1041
- when :hook_specific_output then 'hookSpecificOutput'
1042
- when :suppress_output then 'suppressOutput'
1043
- when :stop_reason then 'stopReason'
1044
- when :system_message then 'systemMessage'
1045
- when :async_timeout then 'asyncTimeout'
1046
- else key.to_s
1047
- end
1048
-
1049
- # Recursively convert nested objects
1050
- converted_value = if value.respond_to?(:to_h) && !value.is_a?(Hash)
1051
- value.to_h
1052
- else
1053
- value
1054
- end
1055
- converted[converted_key] = converted_value
1056
- end
1057
- converted
1367
+ HookOutputKeys.normalize(hook_output)
1058
1368
  end
1059
1369
 
1060
1370
  def send_control_request(request)
@@ -1069,10 +1379,25 @@ module ClaudeAgentSDK
1069
1379
  # RuntimeError; the eventual response dropped by the key? guard).
1070
1380
  task = Async::Task.current?
1071
1381
 
1072
- # Reactor callers wait on an Async::Condition; worker-thread callers
1073
- # on a ThreadWaiter. Register atomically with the terminal-state check
1074
- # so EOF cannot strand a sender that missed the final broadcast.
1075
- waiter = task ? Async::Condition.new : ThreadWaiter.new
1382
+ # The waiter is chosen by reactor OWNERSHIP, not by "has a task". Only
1383
+ # a fiber of the reactor that owns this Query (the one #start ran on,
1384
+ # whose read loop does the signaling) checks its result slot and parks
1385
+ # with no chance for the read loop to run in between, so only there is
1386
+ # the edge-triggered Async::Condition safe. Every other caller races
1387
+ # the read loop between its check and its park and gets the
1388
+ # level-triggered ThreadWaiter: a FiberBoundary worker thread, a plain
1389
+ # thread, and also a fiber on ANOTHER thread's reactor (`Sync {
1390
+ # client.interrupt }` on a request thread while the session lives on a
1391
+ # background reactor; a Sync block inside a :thread-mode callback).
1392
+ # Given a Condition, such a fiber could lose its wakeup on async >=
1393
+ # 2.29, and on async 2.10-2.28 the cross-thread signal raised
1394
+ # FiberError in the read loop, ending the whole session. The deadline
1395
+ # mechanism (#await_control_response) is still chosen by "has a task".
1396
+ #
1397
+ # Register atomically with the terminal-state check so EOF cannot
1398
+ # strand a sender that missed the final broadcast.
1399
+ on_owning_reactor = task && Fiber.scheduler.equal?(@owning_scheduler)
1400
+ waiter = on_owning_reactor ? Async::Condition.new : ThreadWaiter.new
1076
1401
  request_id = @request_counter_mutex.synchronize do
1077
1402
  raise @control_stream_error if @control_stream_error
1078
1403
 
@@ -1122,7 +1447,12 @@ module ClaudeAgentSDK
1122
1447
  # only this deadline is translated, not an outer task's cancellation.
1123
1448
  FiberBoundary.with_cooperative_timeout(task, timeout_seconds, on_timeout: expired) do
1124
1449
  yield
1125
- waiter.wait until @pending_control_results.key?(request_id)
1450
+ # A ThreadWaiter here belongs to a fiber on a reactor that does not
1451
+ # own this Query: Thread::Queue#pop parks that fiber through its
1452
+ # own scheduler, and the deadline cancels it like any suspension.
1453
+ until @pending_control_results.key?(request_id)
1454
+ waiter.is_a?(ThreadWaiter) ? waiter.wait(nil) : waiter.wait
1455
+ end
1126
1456
  end
1127
1457
  else
1128
1458
  # Only schedulerless callers use stdlib Timeout. A fresh, private
@@ -1399,45 +1729,62 @@ module ClaudeAgentSDK
1399
1729
  })
1400
1730
  end
1401
1731
 
1402
- # Wait for a run-ending result before closing stdin when hooks, SDK MCP
1403
- # servers or a can_use_tool callback may still need to exchange control
1404
- # messages with the CLI.
1405
- # The control protocol requires stdin to stay open for the entire turn
1406
- # (hook replies, can_use_tool replies and SDK MCP tool results are all
1407
- # written to stdin), so no timeout is applied — closing stdin mid-turn
1408
- # silently broke hooks/MCP on turns longer than the old 60s bound
1409
- # (mirrors Python SDK commit c3d96cb). A result frame ends one turn, not
1410
- # necessarily the run: while background tasks are in flight the result
1411
- # branch withholds the signal (Python #1088/#1103), and each deferring
1412
- # task's completion wakes the parent for a follow-up turn that ends in
1413
- # another result. The condition is guaranteed to be signaled: by the
1414
- # result branch in read_messages once no tasks are in flight, or by its
1415
- # ensure block when the process exits early.
1732
+ # Wait for the end of the run, when hooks, SDK MCP servers or a
1733
+ # can_use_tool callback may still need to exchange control messages with
1734
+ # the CLI, then close stdin. Their replies are all written to stdin, so it
1735
+ # must stay open until the run ends: at the CLI's "idle" session state
1736
+ # after a result, or, from a CLI that reports no session state, at the
1737
+ # first result with no tracked tasks in flight. A result frame ends one
1738
+ # turn, not necessarily the run: background tasks keep running past it,
1739
+ # or have just finished and still wake the parent for a follow-up turn,
1740
+ # and those turns need stdin for control responses (Python #1088, #1190).
1416
1741
  #
1417
- # Known limitation (same as Python's): the condition is one-shot and is
1418
- # not aware of prompt messages still queued CLI-side, so an Enumerator
1419
- # prompt yielding several user messages (several turns) releases the hold
1420
- # at the first turn boundary with no tracked tasks; control requests from
1421
- # later turns can then find stdin closed. Single-message and String
1422
- # prompts — the common one-shot shapes — are fully covered.
1742
+ # No timeout bounds a turn (closing stdin mid-turn silently broke
1743
+ # hooks/MCP on turns longer than the old 60s bound; Python c3d96cb). The
1744
+ # wait BETWEEN turns is bounded: if the CLI still reports "running"
1745
+ # CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS (10 minutes by default, 0 for no
1746
+ # limit) after a result with no new turn, the run ends anyway
1747
+ # (#arm_run_end_ceiling). A turn under way, a request the SDK is still
1748
+ # answering and a tracked background agent still in flight (Python #1088)
1749
+ # stop that clock. The run is guaranteed to end: as above, or in
1750
+ # read_messages' ensure when the process exits early.
1751
+ #
1752
+ # Known limitation (same as Python's): from a CLI that reports no session
1753
+ # state, a result for an earlier message of an Enumerator prompt still
1754
+ # ends the run even when a later message is already queued CLI-side, so
1755
+ # control requests from that later turn can find stdin closed.
1756
+ # Single-message and String prompts are fully covered.
1423
1757
  def wait_for_result_and_end_input
1424
- @first_result_condition.wait if !@first_result_received && bidirectional_needs?
1758
+ @run_end.wait if bidirectional_needs?
1425
1759
  ensure
1760
+ @run_final = true
1761
+ clear_run_end_ceiling
1426
1762
  @transport.end_input
1427
1763
  end
1428
1764
 
1429
- # Stream input messages to transport. NOTE: iteration runs on the
1430
- # reactor (the deliberate FiberBoundary carve-out — see
1431
- # fiber_boundary.rb): scheduler-aware blocking (Thread::Queue#pop,
1432
- # sleep, socket IO) parks only this task; CPU-bound or scheduler-opaque
1433
- # work in the enumerator must be moved to a producer Thread by the user.
1765
+ # Stream input messages to transport, then close stdin once the run ends
1766
+ # (#wait_for_result_and_end_input). Each message written owes a run of its
1767
+ # own, so the wait is for the last message's run, not an earlier one's.
1768
+ #
1769
+ # NOTE: iteration runs on the reactor (the deliberate FiberBoundary
1770
+ # carve-out — see fiber_boundary.rb): scheduler-aware blocking
1771
+ # (Thread::Queue#pop, sleep, socket IO) parks only this task; CPU-bound or
1772
+ # scheduler-opaque work in the enumerator must be moved to a producer
1773
+ # Thread by the user.
1434
1774
  def stream_input(stream)
1435
1775
  wrote_message = false
1436
1776
  stream.each do |message|
1437
1777
  break if @closed
1438
1778
 
1439
- serialized = message.is_a?(Hash) ? JSON.generate(message) : message.to_s
1440
- writeln(serialized)
1779
+ # Serialized first: a message verbatim_prompts cannot mark raises
1780
+ # here, before the run is reopened for a message that never went out.
1781
+ line = Query.serialize_user_message(message, @verbatim_prompts)
1782
+ # This message owes a run of its own, result included: an earlier
1783
+ # one having ended does not end it.
1784
+ reopen_run
1785
+ @result_received = false
1786
+ clear_run_end_ceiling
1787
+ writeln(line)
1441
1788
  wrote_message = true
1442
1789
  end
1443
1790
  rescue StandardError => e
@@ -1446,22 +1793,33 @@ module ClaudeAgentSDK
1446
1793
  ensure
1447
1794
  # Three teardown shapes:
1448
1795
  # - #close in progress (@closed, Async::Stop unwinding): do nothing —
1449
- # the transport is about to be closed, and waiting on
1450
- # @first_result_condition inside a stopping fiber could suspend
1451
- # teardown. Mirrors Python, where cancellation skips this entirely.
1796
+ # the transport is about to be closed, and waiting on the run's end
1797
+ # inside a stopping fiber could suspend teardown. Mirrors Python,
1798
+ # where cancellation skips this entirely.
1452
1799
  # - A turn is in flight (some message reached the CLI): hold stdin
1453
- # open until its first result so hooks/SDK MCP control replies can
1454
- # still be written (no timeout — the result or process exit is
1455
- # guaranteed to signal).
1800
+ # open until the run ends so hooks/SDK MCP control replies can still
1801
+ # be written (the run's end or process exit is guaranteed to signal).
1456
1802
  # - No complete message ever reached the CLI (empty stream, or the
1457
- # stream raised before the first write): no result can ever arrive,
1458
- # so waiting would park query() forever beside an idle CLI. Close
1459
- # stdin so the CLI sees EOF and exits. Deliberate improvement over
1460
- # Python, which leaves stdin open and hangs on this path.
1803
+ # stream raised before the first write): no result is owed, so there
1804
+ # is nothing to wait for. Close stdin at once so the CLI sees EOF and
1805
+ # exits, as the Python and TypeScript SDKs do.
1806
+ # This is a trade-off, not a free win. An empty stream is also how a
1807
+ # caller says "send nothing, just resume", and a resumed session can
1808
+ # have work of its own: a tool call that a PreToolUse hook deferred
1809
+ # is re-run by the CLI on resume. If that tool is served by an SDK
1810
+ # MCP server, the CLI's request for it finds stdin already closed and
1811
+ # the CLI exits with an error (ProcessError, exit code 1;
1812
+ # anthropics/claude-agent-sdk-python#1226). Waiting instead would
1813
+ # hang every empty resume that has nothing pending: a CLI that is
1814
+ # idle with no input reports no session state (seen with 2.1.286),
1815
+ # so the two cases cannot be told apart without a signal from the
1816
+ # CLI. Until there is one, resume a deferred tool through Client,
1817
+ # which keeps stdin open.
1461
1818
  unless @closed
1462
1819
  if wrote_message
1463
1820
  wait_for_result_and_end_input
1464
1821
  else
1822
+ @run_final = true
1465
1823
  @transport.end_input
1466
1824
  end
1467
1825
  end
@@ -1484,8 +1842,21 @@ module ClaudeAgentSDK
1484
1842
  # reception — ResultMessage dropped, the query reported as complete, and
1485
1843
  # on_error never fired. `while` propagates it like any other error.
1486
1844
  while true # rubocop:disable Style/InfiniteLoop
1845
+ # End of stream is sticky. The read loop enqueues ONE sentinel and is
1846
+ # gone, so once a receive has consumed it, every later one must end
1847
+ # at once rather than wait on a queue nothing writes to any more
1848
+ # (Python closes the send side of its stream, so later iterations
1849
+ # end at once there too). The end is remembered, not put back on the
1850
+ # queue: a receive can be made after the reactor has finished, and
1851
+ # async < 2.29 cannot enqueue outside a task. Anything still queued,
1852
+ # such as a mirror error reported after the end, is delivered first.
1853
+ break if @stream_ended && @message_queue.empty?
1854
+
1487
1855
  message = @message_queue.dequeue
1488
- break if message[:type] == 'end'
1856
+ if message[:type] == 'end'
1857
+ @stream_ended = true
1858
+ break
1859
+ end
1489
1860
  raise message[:error] if message[:type] == 'error'
1490
1861
 
1491
1862
  block.call(message)
@@ -1500,14 +1871,18 @@ module ClaudeAgentSDK
1500
1871
  # plain user threads — don't have; stopping from one raised NoMethodError
1501
1872
  # and left the read/child tasks running. Such callers hand the close to
1502
1873
  # the reactor-side watcher (spawned in #start) and wait for it to finish,
1503
- # so close semantics are identical regardless of the calling thread.
1874
+ # so close semantics are identical regardless of the calling thread. The
1875
+ # reactor waits for a close it was handed: it stays alive until the
1876
+ # transport's teardown is done, even when the session's own task has
1877
+ # nothing left to do.
1504
1878
  def close
1505
1879
  if @close_watcher&.alive? && !Fiber.scheduler.equal?(@owning_scheduler)
1506
1880
  marshal_close_to_reactor
1507
1881
  else
1508
- # Same scheduler (reactor-side caller, including the watcher itself),
1509
- # or no live watcher: when the reactor is gone its task fibers are
1510
- # dead, so stopping them no longer touches Fiber.scheduler.
1882
+ # Same scheduler (reactor-side caller, including the task serving a
1883
+ # marshalled close), or no live reactor-side task to hand it to: when
1884
+ # the reactor is gone its task fibers are dead, so stopping them no
1885
+ # longer touches Fiber.scheduler.
1511
1886
  close_now
1512
1887
  end
1513
1888
  end
@@ -1588,8 +1963,8 @@ module ClaudeAgentSDK
1588
1963
  # surfaces as Async::Stop (Python parity: a hook that awaits
1589
1964
  # disconnect() gets CancelledError). Applied ONLY inside the trees of
1590
1965
  # the tasks being stopped: the reactor-side caller (Client#disconnect
1591
- # from the connect task, the close watcher) and foreign threads keep
1592
- # the plain path, unchanged.
1966
+ # from the connect task, the task serving a marshalled close) and
1967
+ # foreign threads keep the plain path, unchanged.
1593
1968
  #
1594
1969
  # The deferred Stop SUPERSEDES anything the teardown raises: async
1595
1970
  # raises it from defer_stop's ensure with an explicit `cause:`, so a
@@ -1665,11 +2040,51 @@ module ClaudeAgentSDK
1665
2040
  nil
1666
2041
  end
1667
2042
 
1668
- # Hand the close to the reactor and wait for completion. Polls watcher
1669
- # liveness instead of waiting forever: if the reactor shuts down
1670
- # concurrently (the transient watcher is stopped without serving the
1671
- # request), no reply will ever arrive — fall back to a direct close,
1672
- # which is safe once the reactor's fibers are dead.
2043
+ # Reactor side of a marshalled close; runs on the close watcher, which
2044
+ # must not run the close itself. The watcher is transient, and a reactor
2045
+ # does not wait for transient tasks: stopping the read task releases the
2046
+ # session's own task (the end sentinel), and if that was the last
2047
+ # non-transient task the reactor winds down and stops the watcher at the
2048
+ # first suspension point of the transport teardown. SubprocessCLITransport
2049
+ # then TERMs the CLI instead of closing stdin and granting the grace
2050
+ # period that lets it finish its last session write, and the caller is
2051
+ # released before the child is reaped.
2052
+ #
2053
+ # So the close runs in a task of its own that the reactor does wait for
2054
+ # (bounded by the transport's teardown). `defer_stop` on the watcher is
2055
+ # not enough: async 2.10 terminates the tasks of a finished reactor with
2056
+ # repeated stops, and a deferral survives only the first.
2057
+ #
2058
+ # The task is a SIBLING of the watcher, created under the watcher's
2059
+ # current parent. A reactor counts only its direct children, so a child
2060
+ # of the transient watcher would not hold it open — and that is what
2061
+ # `parent.async` creates once the task that started the session is gone
2062
+ # and async has re-parented the watcher to the reactor (Scheduler#async
2063
+ # adopts the current task as the parent).
2064
+ #
2065
+ # @close_watcher follows the close: the waiting caller polls it for
2066
+ # liveness (#marshal_close_to_reactor), so it has to name the task that
2067
+ # will answer. Switched before the new task can suspend, while the
2068
+ # watcher that spawned it is still alive.
2069
+ def serve_marshalled_close(reply)
2070
+ body = FiberBoundary.capture_otel_context do |task|
2071
+ @close_watcher = task
2072
+ begin
2073
+ close
2074
+ ensure
2075
+ reply << true
2076
+ end
2077
+ end
2078
+ Async::Task.new(Async::Task.current.parent, &body).run
2079
+ end
2080
+
2081
+ # Hand the close to the reactor and wait for completion. Polls the
2082
+ # liveness of the reactor-side task that is to answer (@close_watcher:
2083
+ # the idle watcher, then the task serving the close) instead of waiting
2084
+ # forever: if the reactor shuts down concurrently (the transient watcher
2085
+ # is stopped without serving the request), no reply will ever arrive —
2086
+ # fall back to a direct close, which is safe once the reactor's fibers
2087
+ # are dead.
1673
2088
  def marshal_close_to_reactor
1674
2089
  reply = ::Thread::Queue.new
1675
2090
  @close_requests << reply