Skip to content

Commit bafb881

Browse files
committed
Merge origin/main into feat/include-hook-events
2 parents c996651 + f2389ec commit bafb881

3 files changed

Lines changed: 85 additions & 12 deletions

File tree

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Subprocess transport implementation using Claude Code CLI."""
22

3+
import atexit
34
import json
45
import logging
56
import os
67
import platform
78
import re
89
import shutil
10+
import signal
911
from collections.abc import AsyncIterable, AsyncIterator
1012
from contextlib import suppress
1113
from pathlib import Path
@@ -28,6 +30,22 @@
2830
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
2931
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"
3032

33+
# Track live CLI subprocesses so we can terminate them when the parent Python
34+
# process exits. This mirrors the TypeScript SDK's parent-exit cleanup and
35+
# prevents orphaned `claude` processes from leaking when callers crash or exit
36+
# before awaiting close().
37+
_ACTIVE_CHILDREN: set[Process] = set()
38+
39+
40+
def _kill_active_children() -> None:
41+
for p in list(_ACTIVE_CHILDREN):
42+
with suppress(Exception):
43+
p.send_signal(signal.SIGTERM) # On Windows anyio maps this to terminate()
44+
_ACTIVE_CHILDREN.clear()
45+
46+
47+
atexit.register(_kill_active_children)
48+
3149

3250
class SubprocessCLITransport(Transport):
3351
"""Subprocess transport using Claude Code CLI."""
@@ -319,6 +337,9 @@ def _build_command(self) -> list[str]:
319337
if self._options.include_hook_events:
320338
cmd.append("--include-hook-events")
321339

340+
if self._options.strict_mcp_config:
341+
cmd.append("--strict-mcp-config")
342+
322343
if self._options.fork_session:
323344
cmd.append("--fork-session")
324345

@@ -459,6 +480,7 @@ async def connect(self) -> None:
459480
env=process_env,
460481
user=self._options.user,
461482
)
483+
_ACTIVE_CHILDREN.add(self._process)
462484

463485
if self._process.stdout:
464486
self._stdout_stream = TextReceiveStream(self._process.stdout)
@@ -542,23 +564,26 @@ async def close(self) -> None:
542564
# The subprocess needs time to flush its session file after receiving
543565
# EOF on stdin. Without this grace period, SIGTERM can interrupt the
544566
# write and cause the last assistant message to be lost (see #625).
545-
if self._process.returncode is None:
546-
try:
547-
with anyio.fail_after(5):
548-
await self._process.wait()
549-
except TimeoutError:
550-
# Graceful shutdown timed out — force terminate
551-
with suppress(ProcessLookupError):
552-
self._process.terminate()
567+
try:
568+
if self._process.returncode is None:
553569
try:
554570
with anyio.fail_after(5):
555571
await self._process.wait()
556572
except TimeoutError:
557-
# SIGTERM handler blocked — force kill (SIGKILL)
573+
# Graceful shutdown timed out — force terminate
558574
with suppress(ProcessLookupError):
559-
self._process.kill()
560-
with suppress(Exception):
561-
await self._process.wait()
575+
self._process.terminate()
576+
try:
577+
with anyio.fail_after(5):
578+
await self._process.wait()
579+
except TimeoutError:
580+
# SIGTERM handler blocked — force kill (SIGKILL)
581+
with suppress(ProcessLookupError):
582+
self._process.kill()
583+
with suppress(Exception):
584+
await self._process.wait()
585+
finally:
586+
_ACTIVE_CHILDREN.discard(self._process)
562587

563588
self._process = None
564589
self._stdout_stream = None

src/claude_agent_sdk/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,13 @@ class ClaudeAgentOptions:
15421542
to an MCP config JSON file.
15431543
"""
15441544

1545+
strict_mcp_config: bool = False
1546+
"""When ``True``, only use MCP servers passed via :attr:`mcp_servers`,
1547+
ignoring all other MCP configurations the CLI would otherwise load (e.g.
1548+
project ``.mcp.json``, user/global settings, plugin-provided servers).
1549+
Maps to the CLI's ``--strict-mcp-config`` flag and matches the TypeScript
1550+
SDK's ``strictMcpConfig`` option."""
1551+
15451552
permission_mode: PermissionMode | None = None
15461553
"""Permission mode for the session.
15471554

tests/test_transport.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ def test_build_command_include_hook_events(self):
9090
cmd_off = transport_off._build_command()
9191
assert "--include-hook-events" not in cmd_off
9292

93+
def test_build_command_strict_mcp_config(self):
94+
"""Test that --strict-mcp-config is emitted only when enabled."""
95+
transport = SubprocessCLITransport(
96+
prompt="test", options=make_options(strict_mcp_config=True)
97+
)
98+
assert "--strict-mcp-config" in transport._build_command()
99+
100+
transport = SubprocessCLITransport(prompt="test", options=make_options())
101+
assert "--strict-mcp-config" not in transport._build_command()
102+
93103
def test_cli_path_accepts_pathlib_path(self):
94104
"""Test that cli_path accepts pathlib.Path objects."""
95105
from pathlib import Path
@@ -2050,3 +2060,34 @@ async def _test():
20502060
mock_logger.warning.assert_not_called()
20512061

20522062
anyio.run(_test)
2063+
2064+
2065+
class TestAtexitChildCleanup:
2066+
"""Tests for the atexit handler that terminates orphaned CLI subprocesses."""
2067+
2068+
def test_kill_active_children_terminates_process(self) -> None:
2069+
import sys
2070+
2071+
from claude_agent_sdk._internal.transport import subprocess_cli
2072+
2073+
async def _test() -> None:
2074+
proc = await anyio.open_process(
2075+
[sys.executable, "-c", "import time; time.sleep(30)"]
2076+
)
2077+
subprocess_cli._ACTIVE_CHILDREN.add(proc)
2078+
try:
2079+
assert proc.returncode is None
2080+
2081+
subprocess_cli._kill_active_children()
2082+
2083+
assert not subprocess_cli._ACTIVE_CHILDREN
2084+
with anyio.fail_after(5):
2085+
await proc.wait()
2086+
assert proc.returncode is not None
2087+
finally:
2088+
subprocess_cli._ACTIVE_CHILDREN.discard(proc)
2089+
if proc.returncode is None:
2090+
proc.kill()
2091+
await proc.wait()
2092+
2093+
anyio.run(_test)

0 commit comments

Comments
 (0)