From ee08fa230bb771f61ff3e4b35979dd4dbd3d03c7 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sun, 25 Oct 2026 19:06:08 +0800 Subject: [PATCH 1/3] Add ClaudeAgentOptions#process_group (issue #175) In a terminal, Ctrl-C sends SIGINT to the whole foreground process group. The CLI was spawned in the host's group, so it got the signal at the same moment as the host and ended the turn on its own, before the host could choose to interrupt (keeping the partial reply), ask, or ignore it. A host cannot work around that with trap('INT'): the terminal signals every member of the group regardless. process_group: true (default false) spawns the session's CLI with pgroup: true on Unix, new_pgroup: true on Windows (Unix rejects that key, so the choice is platform-gated). The key is absent otherwise, like :uid. The short `claude -v` probe keeps the host's group. Teardown is unchanged: close, the TERM/KILL ladder and the at_exit reaper all signal the pid, not the group. SDK-only; nothing is sent to the CLI. The option follows the existing Boolean pattern (coercing writer, strict predicate, false seeded before the configured-defaults merge), so a configured true wins over an omitted value or nil and an explicit false overrides it. Ruby-only: the Python SDK's equivalent (PR #617) is unmerged as of 2026-10-25. docs/options.md states the contract: the trap must only record the signal (Client#interrupt takes a mutex and raises ThreadError in trap context), when the CLI stops in each exit path, and the background-group caveat for tools that read the terminal. The real-process specs spawn a host that leads its own group, traps INT and runs the transport against FakeClaude, then signal the host's whole group: with the option the CLI answers a fresh control request afterwards; without it the Open3 waiter reports death by SIGINT. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Eff5JM2cdxUsVmJpW4Uw7X --- CHANGELOG.md | 3 + docs/client.md | 2 + docs/options.md | 40 +++++++ .../subprocess_cli_transport.rb | 9 ++ lib/claude_agent_sdk/types/options.rb | 34 ++++++ sig/claude_agent_sdk/types/options.rbs | 9 ++ spec/README.md | 1 + spec/support/process_group_host.rb | 78 ++++++++++++ spec/unit/docs_options_spec.rb | 1 + .../subprocess_cli_transport_fake_cli_spec.rb | 111 +++++++++++++++++- spec/unit/subprocess_cli_transport_spec.rb | 26 ++++ spec/unit/types_spec.rb | 32 +++++ 12 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 spec/support/process_group_host.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe23df5..dae3ac2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **`ClaudeAgentOptions#process_group`** starts the CLI process in a process group of its own (issue #175). In a terminal, Ctrl-C signals the whole foreground process group, so the CLI used to get the SIGINT along with your process and end the turn on its own before your code could decide what to do. With `process_group: true` the terminal's Ctrl-C no longer reaches the CLI, and your process owns it: call `Client#interrupt` or `disconnect` from ordinary code (not from inside the signal trap, where Ruby raises `ThreadError`). Off by default, so nothing changes unless you set it. Sends nothing to the CLI; uses `pgroup: true` on Unix and `new_pgroup: true` on Windows, where it is untested. Teardown is unchanged: the SDK still signals the CLI process, not its group. Ruby only: the Python SDK's equivalent (PR #617) is unmerged as of 2026-10-25. [`docs/options.md`](docs/options.md#process_group) covers the trap pattern, when the CLI stops, and the background-group caveat for tools that read the terminal. + ### Changed - README: the Claude Code overview link (it led to a "Page not found" page) and the built-in tools link now point at `code.claude.com`. "Rails in a minute" is a section of its own after the Quick Start, and its job turns off auto-memory (`CLAUDE_CODE_DISABLE_AUTO_MEMORY`) as the Rails guide's does. The hooks example defines the two callbacks it passes, the custom-tool example runs a prompt, and the Examples table lists `subagent_status_example.rb`. What a session without credentials, or with a rejected key, returns is now described in a new "Authentication Failures" section of `docs/errors.md`. diff --git a/docs/client.md b/docs/client.md index 0b82dd7e..bbe5cde4 100644 --- a/docs/client.md +++ b/docs/client.md @@ -64,6 +64,8 @@ ClaudeAgentSDK::Client.open do |client| end ``` +To handle Ctrl-C yourself in a terminal app (call `interrupt`, ask, or ignore it) rather than have it stop the CLI too, see [`process_group`](options.md#process_group). + The Ruby-style names above sit next to the Python SDK's spellings, and both work, so code ported from the Python docs runs unchanged: | Ruby style | Python-parity name | diff --git a/docs/options.md b/docs/options.md index 5c7d9ad0..9059cff7 100644 --- a/docs/options.md +++ b/docs/options.md @@ -170,6 +170,7 @@ Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#cust | `cwd` | `String \| Pathname` | `nil` | The working directory of the CLI process (and its `PWD`) | | `env` | `Hash[String \| Symbol, String?]` | `{}` | Merged over the environment the CLI process inherits. A `nil` value unsets the variable | | `user` | `String \| Integer` | `nil` | The user the CLI process runs as (name or uid; Unix, and the SDK process needs the privilege to switch) | +| `process_group` | `bool` | `false`; explicit `nil` stays `nil` | Nothing (SDK only): the CLI process starts in a process group of its own, out of reach of the terminal's Ctrl-C. See [`process_group`](#process_group) | | `extra_args` | `Hash[String \| Symbol, untyped]` | `{}` | `-- ` per pair, `--` for a `nil` value. See [`extra_args`](#extra_args) | | `stderr` | `_StderrCallback` | `nil` | Nothing (SDK only): called with each line the CLI writes to stderr | | `debug_stderr` | `_Puts \| String` | `nil` | Nothing (SDK only): an IO-like object (`puts`) or a file path that receives each CLI stderr line | @@ -177,6 +178,45 @@ Guides: [Vendoring the CLI](cli-installer.md), [Custom Transport](client.md#cust These options describe the process the SDK starts itself. A [custom transport](client.md#custom-transport) starts the CLI its own way and decides what to do with them. +### `process_group` + +In a terminal, Ctrl-C sends SIGINT to every process of the foreground process group. The CLI is in your process's group, so it gets the signal at the same moment your process does and ends the turn on its own, before your code can decide what to do: interrupt and keep the partial reply, ask whether to stop, or ignore the keypress. With `process_group: true` the CLI process the SDK starts for the session begins in a process group of its own, and the terminal's SIGINT no longer reaches it, nor the processes it starts that stay in its group (Bash tool commands, stdio MCP servers). The short `claude -v` version check that runs before it stays in your group. + +It applies to `query()` and `Client` alike, on the default transport. A [custom transport](client.md#custom-transport) starts the CLI its own way. A transport subclass whose `#build_command` wraps the CLI in another program (`docker exec`, `ssh`) puts that local program in a group of its own; the option does not set the process group of a CLI on the far side. This option is in neither the Python nor the TypeScript SDK. + +Your process now owns Ctrl-C. To stop a turn, call `Client#interrupt`, or `Client#disconnect` to end the session; a one-shot `query()` has no handle to interrupt. Neither may be called inside a signal trap: they take a mutex, and Ruby raises `ThreadError` (`can't be called from trap context`). Let the trap only record the signal, and make the call from ordinary code: + +```ruby +interrupts = Thread::Queue.new +trap('INT') { interrupts << :int } # nothing else in here: no SDK calls + +options = ClaudeAgentSDK::ClaudeAgentOptions.new(process_group: true) +ClaudeAgentSDK::Client.open(options: options) do |client| + watcher = Thread.new do + interrupts.pop + client.interrupt # on this thread, outside the trap + end + client.query('Refactor the parser') + client.receive_response { |message| puts message } +ensure + watcher&.kill +end +``` + +A plain script that does not trap INT is no worse off: Ctrl-C still raises `Interrupt` in your process, and the SDK's teardown stops the CLI as it does on any other exception. + +When the CLI process stops: + +- On `Client#disconnect`, or at the end of a `Client.open` block or a `query()` call, the usual teardown runs: the SDK closes the CLI's stdin, gives it a grace period to finish, then sends SIGTERM and finally SIGKILL. +- When the interpreter exits normally without a disconnect, an `at_exit` handler sends SIGTERM to the CLI process. Best effort: it does not wait for the turn. +- When your process dies abruptly (`exit!`, SIGKILL), the SDK cleans up nothing. The CLI sees its stdin close, which normally lets it wind down; how soon is up to the CLI and its background work (see [`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS`](#claude_code_print_bg_wait_ceiling_ms)). + +In every case the SDK signals the CLI process only, not its group. The processes the CLI starts are its own to stop, and with the option on they no longer get the terminal's SIGINT either. + +The CLI's group is a background group of the terminal. A process in it that reads from the controlling terminal (a tool command that prompts on the tty) is stopped with SIGTTIN, and so is the rest of the group, the CLI included when the tool shares its group. Whether writing to the terminal stops it too depends on the terminal's TOSTOP setting. The SDK's own traffic with the CLI goes through pipes and is unaffected. + +On Windows the option starts the CLI with `new_pgroup: true` (`CREATE_NEW_PROCESS_GROUP`), which also turns Ctrl-C off for the CLI process. It has not been exercised on Windows. + ### `extra_args` The way to pass a CLI flag the SDK has no option for. A key is the flag's name without the leading dashes; the value follows it, and `nil` means a flag without a value: diff --git a/lib/claude_agent_sdk/subprocess_cli_transport.rb b/lib/claude_agent_sdk/subprocess_cli_transport.rb index 0028923f..a02995ea 100644 --- a/lib/claude_agent_sdk/subprocess_cli_transport.rb +++ b/lib/claude_agent_sdk/subprocess_cli_transport.rb @@ -373,6 +373,15 @@ def connect # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Met # On Windows spawn raises for :uid, wrapped below into # CLIConnectionError — fail-loud instead of the old silent ignore. opts = { chdir: @cwd&.to_s, uid: @options.user }.compact + # process_group: the terminal's Ctrl-C signals the whole foreground + # process group, so a CLI left in ours ends the turn on its own + # before the host can choose to interrupt, ask, or ignore it. Its own + # group keeps that decision with the host. Teardown still signals + # the pid, not the group. Windows has no :pgroup; :new_pgroup + # (CREATE_NEW_PROCESS_GROUP) is its equivalent, and Unix rejects it. + # Absent unless set, like :uid. The `-v` probe is short-lived and + # bounded, and stays in our group. + opts[Gem.win_platform? ? :new_pgroup : :pgroup] = true if @options.process_group? @stdin, @stdout, @stderr, @process = Open3.popen3(process_env, *cmd, opts) # The CLI emits UTF-8 regardless of the parent locale. popen3 pipes diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index 1353185c..c6789d4d 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -84,6 +84,7 @@ def initialize(attributes = {}) self.strict_mcp_config = false self.forward_subagent_text = false self.verbatim_prompts = false + self.process_group = false super(merge_with_defaults(attributes || {})) @@ -247,6 +248,39 @@ def verbatim_prompts=(value) @verbatim_prompts = coerce_boolean(value) end + # Start the CLI process in a process group of its own. Defaults to + # `false`. + # + # In a terminal, Ctrl-C sends SIGINT to the whole foreground process + # group, and the CLI is in your process's group unless this is set: it + # ends the turn on its own at the same moment your process sees the + # signal, before you can decide what to do about it. When true, the + # terminal's SIGINT no longer reaches the CLI (nor the processes it + # starts in its own group), and stopping a turn is up to you: + # {Client#interrupt} or {Client#disconnect}, called from ordinary code, + # never from inside a signal trap. + # + # Applies to the CLI process the default transport starts for the + # session (`pgroup: true` on Unix, `new_pgroup: true` on Windows), not to + # the short `claude -v` version probe. SDK only: nothing is sent to the + # CLI. Not in the Python or TypeScript SDK. + # + # Assigning coerces to a Boolean; {#process_group?} is the predicate + # form. + # + # @return [Boolean] + attr_reader :process_group + + # @return [Boolean] {#process_group}, as a strict Boolean. + def process_group? + !!process_group + end + + # @see #process_group + def process_group=(value) + @process_group = coerce_boolean(value) + end + # Request model-generated progress summaries for subagent (`local_agent`) # tasks. `true` *requests* generation: while the CLI has it enabled, a # subagent's {TaskProgressMessage#summary} **may** carry a one-line status. diff --git a/sig/claude_agent_sdk/types/options.rbs b/sig/claude_agent_sdk/types/options.rbs index 2a503000..e8aadf12 100644 --- a/sig/claude_agent_sdk/types/options.rbs +++ b/sig/claude_agent_sdk/types/options.rbs @@ -57,6 +57,7 @@ module ClaudeAgentSDK ?can_use_tool: _CanUseTool?, ?hooks: Hash[String | Symbol, Array[HookMatcher]?]?, ?user: (String | Integer)?, + ?process_group: bool?, ?agents: Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]]?, ?setting_sources: Array[String]?, ?skills: (String | Array[String])?, @@ -158,6 +159,14 @@ module ClaudeAgentSDK # User (name or uid) to run the CLI process as. attr_accessor user: (String | Integer)? + # Start the CLI process in a process group of its own, so the terminal's + # Ctrl-C does not reach it (false by default). + attr_reader process_group: bool? + + def process_group=: (untyped value) -> bool? + + def process_group?: () -> bool + attr_accessor agents: Hash[String | Symbol, AgentDefinition | Hash[Symbol | String, untyped]]? # Entries of SETTING_SOURCES. diff --git a/spec/README.md b/spec/README.md index afbc2cca..5994228b 100644 --- a/spec/README.md +++ b/spec/README.md @@ -67,6 +67,7 @@ The SessionStore reference adapters under `examples/session_stores/`. The S3 one - **`scripted_cli.rb`** — `ScriptedCLI`, the CLI's end of the control protocol in process. `ScriptedCLI.session(options) { |cli, client| ... }` opens a real `Client` on it; `cli.request(...)` sends a control request (`hook_callback`, `can_use_tool`, `mcp_message`) and returns the control response the SDK wrote; `initialize` is answered the way CLI 2.1.286 answers it. - **`fake_claude.rb`** — `FakeClaude`, a stand-in for the `claude` executable: a plain-Ruby child process that speaks the stream-JSON protocol. `FakeClaude.install(dir)` writes the executable and returns its absolute path for `cli_path:`; `FakeClaude.env(scenario:, log:)` selects what the child does (a conversation, a slow exit after stdin EOF, a process that ignores EOF and SIGTERM, stderr lines followed by a failure, an immediate exit); `FakeClaude.events(log)` reads back what the child received and did. - **`callback_exit_harness.rb`** — `CallbackExitHarness`, the child-process side of `callback_process_exit_spec.rb`. +- **`process_group_host.rb`** — `ProcessGroupHost`, the host process of the Ctrl-C examples in `subprocess_cli_transport_fake_cli_spec.rb` (`process_group`): it traps INT, runs a transport against `FakeClaude` and answers the spec's commands on stdin, one JSON line each. ## Conventions diff --git a/spec/support/process_group_host.rb b/spec/support/process_group_host.rb new file mode 100644 index 00000000..265774cb --- /dev/null +++ b/spec/support/process_group_host.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'json' + +# The host side of the Ctrl-C example in +# spec/unit/subprocess_cli_transport_fake_cli_spec.rb (issue #175), run in a +# CHILD process that the spec starts as the leader of a process group of its +# own, the way a shell starts a foreground job. It traps INT the way a +# terminal app that handles Ctrl-C itself would (the trap only records the +# signal), connects a real SubprocessCLITransport to FakeClaude, and answers +# the spec's commands, one JSON line each: +# +# (at start) ready: pids and process groups of the host and the CLI +# int? whether the host's INT trap ran (waits for it, bounded) +# ping a fresh control request to the CLI, and its response +# waiter how the CLI process ended, from its Open3 waiter +# +# Plain Ruby with no RSpec dependency; spec_helper loads it too, which only +# defines the module. +module ProcessGroupHost + INITIALIZE_ID = 'req_1_0a1b2c3d' + PING_ID = 'req_2_0a1b2c3d' + # Generous: nothing healthy comes near it, and the spec bounds its own wait. + WAIT_SECONDS = 15 + + module_function + + # Child-process entry point: + # `ruby -e 'ProcessGroupHost.main(ARGV)' OPTIONS_JSON`, where OPTIONS_JSON + # holds the ClaudeAgentOptions attributes for the transport. + def main(argv) + $stdout.sync = true + interrupts = Thread::Queue.new + trap('INT') { interrupts << :int } # no SDK call in here: Ruby forbids it in a trap + transport = ClaudeAgentSDK::SubprocessCLITransport.new( + ClaudeAgentSDK::ClaudeAgentOptions.new(JSON.parse(argv.fetch(0))) + ) + transport.connect + child_pid = request(transport, INITIALIZE_ID, subtype: 'initialize', hooks: nil, agents: nil) + .dig(:response, :response, :pid) + reply(event: 'ready', trap: true, host_pid: Process.pid, host_pgid: Process.getpgrp, + child_pid: child_pid, child_pgid: Process.getpgid(child_pid)) + serve(transport, interrupts) + ensure + transport&.close + end + + def serve(transport, interrupts) + while (command = $stdin.gets&.chomp) + case command + when 'int?' then reply(event: 'int', received: !interrupts.pop(timeout: WAIT_SECONDS).nil?) + when 'ping' then reply(event: 'ping', response: bounded { request(transport, PING_ID, subtype: 'mcp_status') }) + when 'waiter' + status = transport.instance_variable_get(:@process).join(WAIT_SECONDS)&.value + reply(event: 'waiter', exited: !status.nil?, termsig: status&.termsig, exitstatus: status&.exitstatus) + end + end + end + + # Writes a control request and returns the first frame the CLI sends + # back: FakeClaude answers a control request before anything else. + def request(transport, id, request) + transport.write("#{JSON.generate(type: 'control_request', request_id: id, request: request)}\n") + transport.read_messages { |frame| break frame } + end + + def bounded(&) + worker = Thread.new(&) + worker.report_on_exception = false # reported in the reply, by #value + worker.join(WAIT_SECONDS) ? worker.value : { error: "no answer within #{WAIT_SECONDS}s" } + rescue StandardError => e + { error: "#{e.class}: #{e.message}" } + end + + def reply(payload) + $stdout.puts(JSON.generate(payload)) + end +end diff --git a/spec/unit/docs_options_spec.rb b/spec/unit/docs_options_spec.rb index f441d809..9788fc65 100644 --- a/spec/unit/docs_options_spec.rb +++ b/spec/unit/docs_options_spec.rb @@ -86,6 +86,7 @@ 'cwd' => [{ cwd: '/srv/app' }], 'env' => [{ env: { 'ANTHROPIC_BASE_URL' => 'https://proxy.example.com' } }], 'user' => [{ user: 'deploy' }], + 'process_group' => [{ process_group: true }], # The flag names are the caller's; the page writes them as `--`. 'extra_args' => [{ extra_args: { 'any-flag' => 'value', 'any-switch' => nil } }], 'stderr' => [{ stderr: callback }], diff --git a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb index 67827bcb..8622fca1 100644 --- a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb +++ b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb @@ -55,7 +55,8 @@ def kill_fake_claude(pid) end # The first exchange of every session, and proof that the child is up and - # reading: the `initialize` request the SDK sends, and the child's answer. + # reading: the `initialize` request the SDK sends, and the child's answer, + # which is returned. def handshake(transport) request = { type: 'control_request', request_id: 'req_1_0a1b2c3d', request: { subtype: 'initialize', hooks: nil, agents: nil } } @@ -63,6 +64,7 @@ def handshake(transport) answer = transport.read_messages { |frame| break frame } expect(answer).to include(type: 'control_response') expect(answer.dig(:response, :request_id)).to eq('req_1_0a1b2c3d') + answer end def events @@ -121,6 +123,113 @@ def events end end + # Issue #175. A terminal's Ctrl-C signals the whole foreground process + # group; with process_group the CLI is out of it. + describe 'process_group' do + before { skip 'process groups are exercised on Unix only' if Gem.win_platform? } + + def child_pgid(process_group:) + transport = transport_for(process_group: process_group) + bounded do + transport.connect + pid = handshake(transport).dig(:response, :response, :pid) + [pid, Process.getpgid(pid)] # while the child is alive + end + ensure + transport&.close + end + + it 'starts the CLI as the leader of a process group of its own, and in ours without it' do + pid, pgid = child_pgid(process_group: true) + expect(pgid).to eq(pid) + expect(pgid).not_to eq(Process.getpgrp) + + _, pgid = child_pgid(process_group: false) + expect(pgid).to eq(Process.getpgrp) + end + + # The keypress itself, played for real: a host process that leads a group + # of its own, as a shell's foreground job does, traps INT and runs the + # transport against the fake CLI (spec/support/process_group_host.rb). + # The example signals the host's whole group, as the terminal does. + describe 'a Ctrl-C to the host process group' do + def now + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + + def start_host(process_group:) + attributes = { cli_path: @cli, cwd: @dir, env: FakeClaude.env(log: @log), process_group: process_group } + @host_err = File.join(@dir, 'host.err') + @host_in, @host_out, @host = Open3.popen2( + RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), '-r', 'claude_agent_sdk', + '-r', File.expand_path('../support/process_group_host.rb', __dir__), + '-e', 'ProcessGroupHost.main(ARGV)', JSON.generate(attributes), pgroup: true, err: @host_err + ) + end + + # The host's next reply, waited for against a deadline. + def host_says(command = nil, seconds: 30) + @host_in.puts(command) if command + deadline = now + seconds + until @host_out.wait_readable([deadline - now, 0].max) + next if now < deadline + + raise "no reply from the host within #{seconds}s; its stderr: #{File.read(@host_err)}" + end + line = @host_out.gets or raise "the host exited; its stderr: #{File.read(@host_err)}" + JSON.parse(line) + end + + # Ready, and the groups are what the example is about: the host leads + # one that is not ours (so the signal below cannot reach rspec), and + # the CLI is in the expected one. + def ready_host(process_group:) + start_host(process_group: process_group) + ready = host_says + @child_pid = ready.fetch('child_pid') + expect(ready).to include('event' => 'ready', 'trap' => true, 'host_pid' => @host.pid) + expect(ready.fetch('host_pgid')).to eq(@host.pid) + expect(ready.fetch('host_pgid')).not_to eq(Process.getpgrp) + expect(ready.fetch('child_pgid')).to eq(process_group ? @child_pid : @host.pid) + ready.fetch('host_pgid') + end + + after do + @host_in&.close + if @host + begin + Process.kill('KILL', -@host.pid) + rescue Errno::ESRCH + nil + end + @host.join(10) + end + # Out of the host's group when isolated: the KILL above misses it. + kill_fake_claude(@child_pid) if @child_pid + end + + it 'leaves the CLI alive and answering, and the host with the signal' do + host_pgid = ready_host(process_group: true) + + Process.kill('INT', -host_pgid) + + expect(host_says('int?')).to include('received' => true) + expect(host_says('ping').fetch('response')).to include( + 'type' => 'control_response', 'response' => include('request_id' => ProcessGroupHost::PING_ID) + ) + end + + it 'kills the CLI with SIGINT along with the host without the option' do + host_pgid = ready_host(process_group: false) + + Process.kill('INT', -host_pgid) + + expect(host_says('int?')).to include('received' => true) + expect(host_says('waiter')).to include('exited' => true, 'termsig' => Signal.list.fetch('INT')) + end + end + end + describe '#close' do # The grace period after stdin EOF exists for this child: the CLI flushes # its session file before it exits, and a SIGTERM in that window loses diff --git a/spec/unit/subprocess_cli_transport_spec.rb b/spec/unit/subprocess_cli_transport_spec.rb index 43c840b7..0b1bb801 100644 --- a/spec/unit/subprocess_cli_transport_spec.rb +++ b/spec/unit/subprocess_cli_transport_spec.rb @@ -792,6 +792,32 @@ def connect_capturing_opts(options) opts = connect_capturing_opts(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude')) expect(opts).not_to have_key(:uid) end + + describe 'process_group option (spawn :pgroup / :new_pgroup)' do + it 'passes pgroup: true when process_group is set' do + opts = connect_capturing_opts(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude', + process_group: true)) + expect(opts).to include(pgroup: true) + expect(opts).not_to have_key(:new_pgroup) + end + + it 'passes neither key when process_group is left out, false or nil' do + [{}, { process_group: false }, { process_group: nil }].each do |setting| + opts = connect_capturing_opts(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude', **setting)) + expect(opts.keys).not_to include(:pgroup, :new_pgroup) + end + end + + # Unix spawn rejects :new_pgroup (ArgumentError: wrong exec option + # symbol), so this only proves which key is chosen. + it 'passes new_pgroup: true instead on Windows' do + allow(Gem).to receive(:win_platform?).and_return(true) + opts = connect_capturing_opts(ClaudeAgentSDK::ClaudeAgentOptions.new(cli_path: '/usr/bin/claude', + process_group: true)) + expect(opts).to include(new_pgroup: true) + expect(opts).not_to have_key(:pgroup) + end + end end describe 'OTel trace context propagation' do diff --git a/spec/unit/types_spec.rb b/spec/unit/types_spec.rb index 0881b66a..9b661e2f 100644 --- a/spec/unit/types_spec.rb +++ b/spec/unit/types_spec.rb @@ -956,6 +956,38 @@ expect(options.forward_subagent_text?).to eq(true) end + describe 'process_group' do + after { ClaudeAgentSDK.reset_configuration } + + it 'defaults to false, keeps an explicit nil, and coerces like the other Booleans' do + expect(described_class.new.process_group).to be(false) + expect(described_class.new(process_group: nil).process_group).to be_nil + expect(described_class.new(process_group: nil).process_group?).to be(false) + expect(described_class.new(process_group: true).process_group?).to be(true) + ['false', 'off', 0].each do |value| + expect(described_class.new(process_group: value).process_group).to be(false) + end + expect(described_class.new('processGroup' => 'true').process_group).to be(true) + end + + it 'lets a configured true win over an omitted value and a nil, and an explicit false override it' do + ClaudeAgentSDK.configure { |config| config.default_options = { process_group: true } } + + expect(described_class.new.process_group).to be(true) + expect(described_class.new(process_group: nil).process_group).to be(true) + expect(described_class.new(process_group: false).process_group).to be(false) + end + + it 'is changed and cleared on a dup_with copy only' do + base = described_class.new(process_group: true) + + expect(base.dup_with(process_group: nil).process_group).to be_nil + expect(base.dup_with(process_group: false).process_group?).to be(false) + expect(base.dup_with(model: 'opus').process_group).to be(true) + expect(base.process_group).to be(true) + end + end + it 'accepts configuration' do options = described_class.new( allowed_tools: %w[Read Write], From dca5e29b05b4d9539abcf78f82361f2694a56593 Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sun, 25 Oct 2026 19:32:48 +0800 Subject: [PATCH 2/3] process_group: contain the Ctrl-C specs' processes, restore the docs trap Review round 1 of #175. The Ctrl-C specs learned the isolated CLI's pid only from the host's ready reply, and cleanup KILLed the host group first. A host that hung before ready therefore lost the only process holding the CLI's waiter while the test never knew the CLI's pid, and an isolated CLI that survives stdin EOF outlived the run. The host now reports the pid as soon as the CLI is spawned; cleanup closes the host's stdin and gives it a bounded chance to close its transport (which reaps the CLI), then KILLs the host group, then every recorded CLI pid (the reported one and those the fake logged), waits against a deadline until they are gone, and fails if any survive. It no longer shells out to ps for processes the example started itself. A new example pins this with a host that stalls before ready and a wedged CLI that ignores EOF and TERM. The host-reply reader bounded only its first byte: a blocking gets after wait_readable could hang the suite on a partial line. It now accumulates with read_nonblock under one monotonic deadline and fails on EOF mid-reply; an example pins the silent, partial and cut-off cases. The docs example replaced the INT handler for good, so after the session Ctrl-C went into a queue nobody read. It now restores the previous handler in an ensure covering the whole session, including a failed connect, and closes the queue so the watcher ends and is joined. The TypeScript claim was imprecise: its SDK has no such option but lets a host spawn the CLI itself (spawnClaudeCodeProcess). Docs and YARD now say that; the Python SDK has no equivalent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Eff5JM2cdxUsVmJpW4Uw7X --- docs/options.md | 25 ++-- lib/claude_agent_sdk/types/options.rb | 3 +- spec/support/process_group_host.rb | 18 ++- .../subprocess_cli_transport_fake_cli_spec.rb | 123 ++++++++++++++---- 4 files changed, 129 insertions(+), 40 deletions(-) diff --git a/docs/options.md b/docs/options.md index 9059cff7..f4794cea 100644 --- a/docs/options.md +++ b/docs/options.md @@ -182,24 +182,27 @@ These options describe the process the SDK starts itself. A [custom transport](c In a terminal, Ctrl-C sends SIGINT to every process of the foreground process group. The CLI is in your process's group, so it gets the signal at the same moment your process does and ends the turn on its own, before your code can decide what to do: interrupt and keep the partial reply, ask whether to stop, or ignore the keypress. With `process_group: true` the CLI process the SDK starts for the session begins in a process group of its own, and the terminal's SIGINT no longer reaches it, nor the processes it starts that stay in its group (Bash tool commands, stdio MCP servers). The short `claude -v` version check that runs before it stays in your group. -It applies to `query()` and `Client` alike, on the default transport. A [custom transport](client.md#custom-transport) starts the CLI its own way. A transport subclass whose `#build_command` wraps the CLI in another program (`docker exec`, `ssh`) puts that local program in a group of its own; the option does not set the process group of a CLI on the far side. This option is in neither the Python nor the TypeScript SDK. +It applies to `query()` and `Client` alike, on the default transport. A [custom transport](client.md#custom-transport) starts the CLI its own way. A transport subclass whose `#build_command` wraps the CLI in another program (`docker exec`, `ssh`) puts that local program in a group of its own; the option does not set the process group of a CLI on the far side. The Python SDK has no equivalent; the TypeScript SDK leaves it to a custom `spawnClaudeCodeProcess`. Your process now owns Ctrl-C. To stop a turn, call `Client#interrupt`, or `Client#disconnect` to end the session; a one-shot `query()` has no handle to interrupt. Neither may be called inside a signal trap: they take a mutex, and Ruby raises `ThreadError` (`can't be called from trap context`). Let the trap only record the signal, and make the call from ordinary code: ```ruby interrupts = Thread::Queue.new -trap('INT') { interrupts << :int } # nothing else in here: no SDK calls - -options = ClaudeAgentSDK::ClaudeAgentOptions.new(process_group: true) -ClaudeAgentSDK::Client.open(options: options) do |client| - watcher = Thread.new do - interrupts.pop - client.interrupt # on this thread, outside the trap +previous = trap('INT') { interrupts << :int } # nothing else in here: no SDK calls +begin + options = ClaudeAgentSDK::ClaudeAgentOptions.new(process_group: true) + ClaudeAgentSDK::Client.open(options: options) do |client| + watcher = Thread.new do + client.interrupt if interrupts.pop # on this thread, outside the trap + end + client.query('Refactor the parser') + client.receive_response { |message| puts message } + ensure + interrupts.close # pop returns nil, and the watcher ends + watcher&.join end - client.query('Refactor the parser') - client.receive_response { |message| puts message } ensure - watcher&.kill + trap('INT', previous) # Ctrl-C behaves as before once the session is over end ``` diff --git a/lib/claude_agent_sdk/types/options.rb b/lib/claude_agent_sdk/types/options.rb index c6789d4d..6f147095 100644 --- a/lib/claude_agent_sdk/types/options.rb +++ b/lib/claude_agent_sdk/types/options.rb @@ -263,7 +263,8 @@ def verbatim_prompts=(value) # Applies to the CLI process the default transport starts for the # session (`pgroup: true` on Unix, `new_pgroup: true` on Windows), not to # the short `claude -v` version probe. SDK only: nothing is sent to the - # CLI. Not in the Python or TypeScript SDK. + # CLI. Ruby only: the Python SDK has no equivalent, and the TypeScript + # SDK leaves it to a custom `spawnClaudeCodeProcess`. # # Assigning coerces to a Boolean; {#process_group?} is the predicate # form. diff --git a/spec/support/process_group_host.rb b/spec/support/process_group_host.rb index 265774cb..d6f5f5a9 100644 --- a/spec/support/process_group_host.rb +++ b/spec/support/process_group_host.rb @@ -10,7 +10,8 @@ # signal), connects a real SubprocessCLITransport to FakeClaude, and answers # the spec's commands, one JSON line each: # -# (at start) ready: pids and process groups of the host and the CLI +# (at start) spawned: the CLI's pid, as soon as the CLI is started +# ready: pids and process groups of the host and the CLI # int? whether the host's INT trap ran (waits for it, bounded) # ping a fresh control request to the CLI, and its response # waiter how the CLI process ended, from its Open3 waiter @@ -26,18 +27,23 @@ module ProcessGroupHost module_function # Child-process entry point: - # `ruby -e 'ProcessGroupHost.main(ARGV)' OPTIONS_JSON`, where OPTIONS_JSON - # holds the ClaudeAgentOptions attributes for the transport. + # `ruby -e 'ProcessGroupHost.main(ARGV)' CONFIG_JSON`, where CONFIG_JSON + # holds `options`, the ClaudeAgentOptions attributes for the transport, + # and `stall_before_ready`, which makes the host hang for good after the + # initialize exchange, never reading its stdin again: what the spec's + # cleanup must survive when readiness fails. def main(argv) $stdout.sync = true + config = JSON.parse(argv.fetch(0)) interrupts = Thread::Queue.new trap('INT') { interrupts << :int } # no SDK call in here: Ruby forbids it in a trap - transport = ClaudeAgentSDK::SubprocessCLITransport.new( - ClaudeAgentSDK::ClaudeAgentOptions.new(JSON.parse(argv.fetch(0))) - ) + transport = ClaudeAgentSDK::SubprocessCLITransport.new(ClaudeAgentSDK::ClaudeAgentOptions.new(config['options'])) transport.connect + # Before anything that could hang, so the spec can always clean the CLI up. + reply(event: 'spawned', child_pid: transport.instance_variable_get(:@process).pid) child_pid = request(transport, INITIALIZE_ID, subtype: 'initialize', hooks: nil, agents: nil) .dig(:response, :response, :pid) + sleep if config['stall_before_ready'] reply(event: 'ready', trap: true, host_pid: Process.pid, host_pgid: Process.getpgrp, child_pid: child_pid, child_pgid: Process.getpgid(child_pid)) serve(transport, interrupts) diff --git a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb index 8622fca1..f6bc3a73 100644 --- a/spec/unit/subprocess_cli_transport_fake_cli_spec.rb +++ b/spec/unit/subprocess_cli_transport_fake_cli_spec.rb @@ -157,27 +157,45 @@ def now Process.clock_gettime(Process::CLOCK_MONOTONIC) end - def start_host(process_group:) - attributes = { cli_path: @cli, cwd: @dir, env: FakeClaude.env(log: @log), process_group: process_group } + # Starts the host and reads its first reply, the CLI's pid: recorded + # before anything that can hang, so the CLI is always cleaned up. + def start_host(process_group:, scenario: 'conversation', stall_before_ready: false) + config = { options: { cli_path: @cli, cwd: @dir, env: FakeClaude.env(scenario: scenario, log: @log), + process_group: process_group }, + stall_before_ready: stall_before_ready } @host_err = File.join(@dir, 'host.err') @host_in, @host_out, @host = Open3.popen2( RbConfig.ruby, '-I', File.expand_path('../../lib', __dir__), '-r', 'claude_agent_sdk', '-r', File.expand_path('../support/process_group_host.rb', __dir__), - '-e', 'ProcessGroupHost.main(ARGV)', JSON.generate(attributes), pgroup: true, err: @host_err + '-e', 'ProcessGroupHost.main(ARGV)', JSON.generate(config), pgroup: true, err: @host_err ) + spawned = host_says + expect(spawned).to include('event' => 'spawned') + @child_pid = spawned.fetch('child_pid') end - # The host's next reply, waited for against a deadline. + # The host's next reply. The whole line is read against one deadline: + # a host that stalls after part of a line fails here as surely as one + # that says nothing. def host_says(command = nil, seconds: 30) @host_in.puts(command) if command deadline = now + seconds - until @host_out.wait_readable([deadline - now, 0].max) - next if now < deadline + @host_buffer ||= +'' + until (newline = @host_buffer.index("\n")) + remaining = deadline - now + raise "no reply from the host within #{seconds}s; its stderr: #{host_stderr}" unless remaining.positive? + next unless @host_out.wait_readable(remaining) - raise "no reply from the host within #{seconds}s; its stderr: #{File.read(@host_err)}" + chunk = @host_out.read_nonblock(4096, exception: false) + raise "the host closed its stdout mid-reply (#{@host_buffer.inspect}); its stderr: #{host_stderr}" unless chunk + + @host_buffer << chunk unless chunk == :wait_readable end - line = @host_out.gets or raise "the host exited; its stderr: #{File.read(@host_err)}" - JSON.parse(line) + JSON.parse(@host_buffer.slice!(0..newline)) + end + + def host_stderr + @host_err && File.exist?(@host_err) ? File.read(@host_err) : '' end # Ready, and the groups are what the example is about: the host leads @@ -186,28 +204,59 @@ def host_says(command = nil, seconds: 30) def ready_host(process_group:) start_host(process_group: process_group) ready = host_says - @child_pid = ready.fetch('child_pid') - expect(ready).to include('event' => 'ready', 'trap' => true, 'host_pid' => @host.pid) + expect(ready).to include('event' => 'ready', 'trap' => true, 'host_pid' => @host.pid, + 'child_pid' => @child_pid) expect(ready.fetch('host_pgid')).to eq(@host.pid) expect(ready.fetch('host_pgid')).not_to eq(Process.getpgrp) expect(ready.fetch('child_pgid')).to eq(process_group ? @child_pid : @host.pid) ready.fetch('host_pgid') end - after do - @host_in&.close - if @host - begin - Process.kill('KILL', -@host.pid) - rescue Errno::ESRCH - nil - end - @host.join(10) + def alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH, Errno::EPERM + false + end + + def kill(signal, pid) + Process.kill(signal, pid) + rescue Errno::ESRCH + nil + end + + # Stops what start_host started, and fails unless both processes are + # gone. The host first gets a bounded chance to close its transport and + # exit on stdin EOF, which reaps the CLI; then its group is KILLed, and + # so is every CLI pid on record (the one the host reported, and those + # the fake logged), since an isolated CLI is out of the host's group. + # These are processes this example started moments ago, so a pid is + # only KILLed while it still leads its own group or is in the host's. + def stop_host(grace: 10) + host = @host or return + @host = nil + @host_in.close + unless host.join(grace) + kill('KILL', -host.pid) + raise 'the host is still running after SIGKILL' unless host.join(10) + end + @host_out.close + cli = [@child_pid, *FakeClaude.pids(@log)].compact.uniq + cli.each do |pid| + kill('KILL', pid) if [pid, host.pid].include?(Process.getpgid(pid)) + rescue Errno::ESRCH + nil + end + deadline = now + 10 + until (cli = cli.select { |pid| alive?(pid) }).empty? + raise "the CLI is still running after SIGKILL: #{cli.inspect}" if now > deadline + + sleep 0.01 # polling for the reaper outside this process, against the deadline end - # Out of the host's group when isolated: the KILL above misses it. - kill_fake_claude(@child_pid) if @child_pid end + after { stop_host } + it 'leaves the CLI alive and answering, and the host with the signal' do host_pgid = ready_host(process_group: true) @@ -227,6 +276,36 @@ def ready_host(process_group:) expect(host_says('int?')).to include('received' => true) expect(host_says('waiter')).to include('exited' => true, 'termsig' => Signal.list.fetch('INT')) end + + # The cleanup above, when readiness never comes: a host that hangs + # without reading stdin, and an isolated CLI that ignores stdin EOF + # and SIGTERM. Neither may outlive the example. + it 'leaves no process behind when the host never gets ready' do + start_host(process_group: true, scenario: 'wedged', stall_before_ready: true) + host = @host + cli = @child_pid + + expect { host_says(seconds: 1) }.to raise_error(/no reply from the host within 1s/) + stop_host(grace: 1) + + expect(host.join(0)).to be(host) + expect(alive?(cli)).to be(false) + end + + it 'bounds a reply that never comes, comes in part, or is cut off' do + [['', true], ['{"event":"ready"', true], ['{"event":"ready"', false]].each do |partial, keep_open| + @host_out, writer = IO.pipe + @host_buffer = nil + writer.write(partial) + writer.close unless keep_open + + expect { bounded(5) { host_says(seconds: 0.2) } } + .to raise_error(keep_open ? /no reply from the host within 0.2s/ : /closed its stdout mid-reply/) + ensure + @host_out&.close + writer&.close + end + end end end From c88f475c6f932ff1fbce415077aa4bdc52ac1a1e Mon Sep 17 00:00:00 2001 From: ya-luotao Date: Sun, 25 Oct 2026 19:44:44 +0800 Subject: [PATCH 3/3] process_group docs: stop the watcher with a sentinel, not by closing the queue Review round 2 of #175. The example closed the trap's queue before Client.open disconnected, while the trap was still installed: a Ctrl-C during disconnect then raised ClosedQueueError inside the trap. A nil sentinel ends the watcher the same way and leaves the queue open until the outer ensure restores the previous handler. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Eff5JM2cdxUsVmJpW4Uw7X --- docs/options.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/options.md b/docs/options.md index f4794cea..13a6bf63 100644 --- a/docs/options.md +++ b/docs/options.md @@ -198,7 +198,7 @@ begin client.query('Refactor the parser') client.receive_response { |message| puts message } ensure - interrupts.close # pop returns nil, and the watcher ends + interrupts << nil # ends the watcher; the queue stays open for the trap watcher&.join end ensure