|
1 | 1 | """Subprocess transport implementation using Claude Code CLI.""" |
2 | 2 |
|
| 3 | +import atexit |
3 | 4 | import json |
4 | 5 | import logging |
5 | 6 | import os |
6 | 7 | import platform |
7 | 8 | import re |
8 | 9 | import shutil |
| 10 | +import signal |
9 | 11 | from collections.abc import AsyncIterable, AsyncIterator |
10 | 12 | from contextlib import suppress |
11 | 13 | from pathlib import Path |
|
28 | 30 | _DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit |
29 | 31 | MINIMUM_CLAUDE_CODE_VERSION = "2.0.0" |
30 | 32 |
|
| 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 | + |
31 | 49 |
|
32 | 50 | class SubprocessCLITransport(Transport): |
33 | 51 | """Subprocess transport using Claude Code CLI.""" |
@@ -319,6 +337,9 @@ def _build_command(self) -> list[str]: |
319 | 337 | if self._options.include_hook_events: |
320 | 338 | cmd.append("--include-hook-events") |
321 | 339 |
|
| 340 | + if self._options.strict_mcp_config: |
| 341 | + cmd.append("--strict-mcp-config") |
| 342 | + |
322 | 343 | if self._options.fork_session: |
323 | 344 | cmd.append("--fork-session") |
324 | 345 |
|
@@ -459,6 +480,7 @@ async def connect(self) -> None: |
459 | 480 | env=process_env, |
460 | 481 | user=self._options.user, |
461 | 482 | ) |
| 483 | + _ACTIVE_CHILDREN.add(self._process) |
462 | 484 |
|
463 | 485 | if self._process.stdout: |
464 | 486 | self._stdout_stream = TextReceiveStream(self._process.stdout) |
@@ -542,23 +564,26 @@ async def close(self) -> None: |
542 | 564 | # The subprocess needs time to flush its session file after receiving |
543 | 565 | # EOF on stdin. Without this grace period, SIGTERM can interrupt the |
544 | 566 | # 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: |
553 | 569 | try: |
554 | 570 | with anyio.fail_after(5): |
555 | 571 | await self._process.wait() |
556 | 572 | except TimeoutError: |
557 | | - # SIGTERM handler blocked — force kill (SIGKILL) |
| 573 | + # Graceful shutdown timed out — force terminate |
558 | 574 | 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) |
562 | 587 |
|
563 | 588 | self._process = None |
564 | 589 | self._stdout_stream = None |
|
0 commit comments