SIGKILL all direct children. The SDK's claude subprocess (Node) does not die when we do — it gets orphaned to init and keeps executing Bash tool calls against whatever container is named find_target. Observed running 11+ hours after its parent died. Walk /proc, find PPID==us, kill. N
()
| 95 | |
| 96 | |
| 97 | def _terminate_subprocesses() -> None: |
| 98 | """SIGKILL all direct children. The SDK's claude subprocess (Node) does not |
| 99 | die when we do — it gets orphaned to init and keeps executing Bash tool |
| 100 | calls against whatever container is named find_target. Observed running |
| 101 | 11+ hours after its parent died. Walk /proc, find PPID==us, kill. |
| 102 | No-op on platforms without /proc (macOS); container cleanup in _on_signal |
| 103 | still removes the targets the orphan would be exec'ing into.""" |
| 104 | if not os.path.isdir("/proc"): |
| 105 | return |
| 106 | me = os.getpid() |
| 107 | for entry in os.scandir("/proc"): |
| 108 | if not entry.name.isdigit(): |
| 109 | continue |
| 110 | try: |
| 111 | with open(f"/proc/{entry.name}/stat", "rb") as f: |
| 112 | # stat format: pid (comm) state ppid ... — comm can contain spaces/parens, |
| 113 | # so split on the last ')' to safely get the fields after it. |
| 114 | after_comm = f.read().rsplit(b")", 1)[1].split() |
| 115 | ppid = int(after_comm[1]) # state=[0], ppid=[1] |
| 116 | if ppid == me: |
| 117 | os.kill(int(entry.name), signal.SIGKILL) |
| 118 | except (FileNotFoundError, ProcessLookupError, PermissionError, IndexError): |
| 119 | pass |
| 120 | |
| 121 | |
| 122 | _current_target_name: str | None = None |