claude-dev-env 8.52.0 → 8.52.1

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.
@@ -25,3 +25,15 @@ PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
25
25
  Gates the kill command alone. Each caller sets its own bound on the drain that
26
26
  follows the kill.
27
27
  """
28
+
29
+ PROCESS_TREE_EXIT_WAIT_SECONDS: float = 2.0
30
+ """Seconds ``terminate_process_tree`` waits for the signalled POSIX group to exit.
31
+
32
+ A SIGKILL takes effect when the kernel next schedules each target, so a
33
+ descendant can still run for a moment after ``killpg`` returns. The wait ends
34
+ as soon as no group member is still running; the bound applies only when a
35
+ member survived the signal.
36
+ """
37
+
38
+ PROCESS_TREE_EXIT_POLL_SECONDS: float = 0.01
39
+ """Pause between checks for running members of the signalled process group."""
@@ -25,6 +25,7 @@ import os
25
25
  import signal
26
26
  import subprocess
27
27
  import sys
28
+ import time
28
29
  from pathlib import Path
29
30
 
30
31
  _config_directory = str(Path(__file__).resolve().parent / "config")
@@ -32,6 +33,8 @@ if _config_directory not in sys.path:
32
33
  sys.path.insert(0, _config_directory)
33
34
 
34
35
  from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
36
+ PROCESS_TREE_EXIT_POLL_SECONDS,
37
+ PROCESS_TREE_EXIT_WAIT_SECONDS,
35
38
  PROCESS_TREE_KILL_TIMEOUT_SECONDS,
36
39
  WINDOWS_TASKKILL_COMMAND,
37
40
  WINDOWS_TASKKILL_FORCE_FLAG,
@@ -126,19 +129,103 @@ def terminate_process_tree(process: subprocess.Popen[str]) -> None:
126
129
  tree kill takes ok: grandchildren die, pipes close
127
130
  tree kill misses the child ok: Popen.kill() ends the direct child
128
131
  child exits mid-kill ok: the raised lookup error is swallowed
132
+ grandchild still running ok: returns once the POSIX group has exited
129
133
 
130
134
  Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
131
- so the caller never waits on a live process.
135
+ so the caller never waits on a live process. On POSIX it then waits, bounded
136
+ by ``PROCESS_TREE_EXIT_WAIT_SECONDS``, until no group member is running, so
137
+ a descendant cannot touch files after this call returns.
132
138
 
133
139
  Args:
134
140
  process: The process whose tree is ended.
135
141
  """
136
142
  if process.poll() is not None:
137
143
  return
144
+ process_group_identifier = _posix_process_group(process.pid)
138
145
  kill_process_tree_by_identifier(process.pid)
139
- if process.poll() is not None:
140
- return
146
+ if process.poll() is None:
147
+ try:
148
+ process.kill()
149
+ except ProcessLookupError:
150
+ pass
151
+ if process_group_identifier is not None:
152
+ _wait_for_process_group_exit(process, process_group_identifier)
153
+
154
+
155
+ def _posix_process_group(process_identifier: int) -> int | None:
156
+ if sys.platform == "win32":
157
+ return None
141
158
  try:
142
- process.kill()
143
- except ProcessLookupError:
144
- return
159
+ return os.getpgid(process_identifier)
160
+ except OSError:
161
+ return None
162
+
163
+
164
+ def _wait_for_process_group_exit(
165
+ process: subprocess.Popen[str], process_group_identifier: int
166
+ ) -> None:
167
+ """Block until no member of the signalled group is still running, bounded.
168
+
169
+ ``killpg`` returns once the signal is queued, and each target dies only when
170
+ the kernel next schedules it. A grandchild preempted inside a system call
171
+ finishes that call first, so it can still truncate or write a file after
172
+ the caller has moved on. Waiting here keeps that work inside the call.
173
+ """
174
+ deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
175
+ while running_process_group_members(process, process_group_identifier):
176
+ if time.monotonic() >= deadline:
177
+ return
178
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
179
+
180
+
181
+ def running_process_group_members(
182
+ process: subprocess.Popen[str], process_group_identifier: int
183
+ ) -> list[int]:
184
+ """List the process ids in a POSIX group that have not exited yet.
185
+
186
+ Reads ``/proc`` where it exists, which tells a running member from a zombie
187
+ waiting on its reaper. Elsewhere it reaps the direct child and asks the
188
+ kernel whether the group still has any member.
189
+
190
+ Args:
191
+ process: The direct child that leads the group.
192
+ process_group_identifier: The group the direct child leads.
193
+
194
+ Returns:
195
+ Process ids still running, or an empty list once the group has exited.
196
+ """
197
+ if sys.platform == "win32":
198
+ return []
199
+ proc_directory = Path("/proc")
200
+ if proc_directory.is_dir():
201
+ return _running_members_from_proc(proc_directory, process_group_identifier)
202
+ try:
203
+ process.wait(timeout=PROCESS_TREE_EXIT_POLL_SECONDS)
204
+ except subprocess.TimeoutExpired:
205
+ return [process.pid]
206
+ try:
207
+ os.killpg(process_group_identifier, 0)
208
+ except OSError:
209
+ return []
210
+ return [process_group_identifier]
211
+
212
+
213
+ def _running_members_from_proc(
214
+ proc_directory: Path, process_group_identifier: int
215
+ ) -> list[int]:
216
+ all_running: list[int] = []
217
+ for each_entry in proc_directory.iterdir():
218
+ if not each_entry.name.isdigit():
219
+ continue
220
+ try:
221
+ stat_text = (each_entry / "stat").read_text(
222
+ encoding="ascii", errors="replace"
223
+ )
224
+ except OSError:
225
+ continue
226
+ state, _parent_identifier, group_identifier, *_later_fields = stat_text.rsplit(
227
+ ")", 1
228
+ )[1].split()
229
+ if int(group_identifier) == process_group_identifier and state != "Z":
230
+ all_running.append(int(each_entry.name))
231
+ return all_running
@@ -7,9 +7,12 @@ the taskkill argv it builds, the failures it swallows, and the
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import os
10
11
  import subprocess
11
12
  import sys
13
+ import time
12
14
  from pathlib import Path
15
+ from typing import cast
13
16
 
14
17
  import pytest
15
18
 
@@ -23,6 +26,8 @@ from process_tree_kill import ( # noqa: E402
23
26
  terminate_process_tree,
24
27
  )
25
28
  from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
29
+ PROCESS_TREE_EXIT_POLL_SECONDS,
30
+ PROCESS_TREE_EXIT_WAIT_SECONDS,
26
31
  PROCESS_TREE_KILL_TIMEOUT_SECONDS,
27
32
  WINDOWS_TASKKILL_COMMAND,
28
33
  WINDOWS_TASKKILL_FORCE_FLAG,
@@ -276,3 +281,147 @@ def test_new_session_is_requested_off_windows_only(
276
281
 
277
282
  _install_windows_platform(monkeypatch)
278
283
  assert process_tree_kill.should_start_new_session() is False
284
+
285
+
286
+ def _install_posix_group_wait(
287
+ monkeypatch: pytest.MonkeyPatch, all_member_snapshots: list[list[int]]
288
+ ) -> list[float]:
289
+ """Script what the group wait sees after the kill, and record each pause.
290
+
291
+ The last snapshot repeats once the list runs dry, so a never-empty script
292
+ models a group that outlives the deadline.
293
+ """
294
+ all_sleeps: list[float] = []
295
+ remaining_snapshots = list(all_member_snapshots)
296
+
297
+ def scripted_members(process: object, process_group_identifier: int) -> list[int]:
298
+ del process, process_group_identifier
299
+ if len(remaining_snapshots) > 1:
300
+ return remaining_snapshots.pop(0)
301
+ return remaining_snapshots[0]
302
+
303
+ _install_posix_platform(monkeypatch)
304
+ monkeypatch.setattr(
305
+ process_tree_kill.os,
306
+ "getpgid",
307
+ lambda process_identifier: FAKE_PROCESS_GROUP_IDENTIFIER,
308
+ raising=False,
309
+ )
310
+ monkeypatch.setattr(
311
+ process_tree_kill.os,
312
+ "killpg",
313
+ lambda group_identifier, signal_number: None,
314
+ raising=False,
315
+ )
316
+ monkeypatch.setattr(
317
+ process_tree_kill, "running_process_group_members", scripted_members
318
+ )
319
+ monkeypatch.setattr(process_tree_kill.time, "sleep", all_sleeps.append)
320
+ return all_sleeps
321
+
322
+
323
+ def test_terminate_waits_until_no_group_member_is_running(
324
+ monkeypatch: pytest.MonkeyPatch,
325
+ ) -> None:
326
+ """The POSIX kill returns only once the signalled group has emptied.
327
+
328
+ ::
329
+
330
+ members [7], [7], [] ok: two pauses, then return
331
+ no wait flag: a grandchild still inside a system call
332
+ writes after the caller has moved on
333
+ """
334
+ all_sleeps = _install_posix_group_wait(monkeypatch, [[7], [7], []])
335
+
336
+ terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
337
+
338
+ assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
339
+
340
+
341
+ def test_terminate_stops_waiting_at_the_exit_deadline(
342
+ monkeypatch: pytest.MonkeyPatch,
343
+ ) -> None:
344
+ """A group member that survives the signal does not hold the caller forever."""
345
+ all_sleeps = _install_posix_group_wait(monkeypatch, [[7]])
346
+ clock = iter(
347
+ [0.0, 0.0, PROCESS_TREE_EXIT_WAIT_SECONDS / 2, PROCESS_TREE_EXIT_WAIT_SECONDS]
348
+ )
349
+ monkeypatch.setattr(process_tree_kill.time, "monotonic", lambda: next(clock))
350
+
351
+ terminate_process_tree(_as_popen(_FakeProcess([None, 0])))
352
+
353
+ assert all_sleeps == [PROCESS_TREE_EXIT_POLL_SECONDS] * 2
354
+
355
+
356
+ def _as_popen(fake_process: _FakeProcess) -> subprocess.Popen[str]:
357
+ return cast("subprocess.Popen[str]", fake_process)
358
+
359
+
360
+ def _group_led_by(process: subprocess.Popen[str]) -> int:
361
+ """Name the group a session-leading child owns; Windows has none, so its pid."""
362
+ if process_tree_kill.should_start_new_session():
363
+ return os.getpgid(process.pid)
364
+ return process.pid
365
+
366
+
367
+ def test_running_members_leave_out_a_zombie() -> None:
368
+ """A member that died but awaits its reaper is not running.
369
+
370
+ ::
371
+
372
+ child sleeping ok: listed on POSIX, [] on Windows
373
+ child killed, not yet reaped ok: dropped, so the wait ends
374
+ """
375
+ sleeper = subprocess.Popen(
376
+ [sys.executable, "-c", "import time; time.sleep(60)"],
377
+ start_new_session=process_tree_kill.should_start_new_session(),
378
+ )
379
+ group_identifier = _group_led_by(sleeper)
380
+ all_expected_running = (
381
+ [sleeper.pid] if process_tree_kill.should_start_new_session() else []
382
+ )
383
+ try:
384
+ assert (
385
+ process_tree_kill.running_process_group_members(sleeper, group_identifier)
386
+ == all_expected_running
387
+ )
388
+ sleeper.kill()
389
+ deadline = time.monotonic() + PROCESS_TREE_EXIT_WAIT_SECONDS
390
+ while process_tree_kill.running_process_group_members(
391
+ sleeper, group_identifier
392
+ ):
393
+ assert time.monotonic() < deadline, "the killed sleeper still counts"
394
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
395
+ finally:
396
+ sleeper.wait()
397
+
398
+
399
+ def test_terminate_returns_with_the_grandchild_already_gone(tmp_path: Path) -> None:
400
+ """Right after the kill returns, no process in the child's group still runs."""
401
+ heartbeat_file = tmp_path / "heartbeat.txt"
402
+ grandchild_source = (
403
+ "import pathlib, sys\n"
404
+ "heartbeat = pathlib.Path(sys.argv[1])\n"
405
+ "while True:\n"
406
+ " heartbeat.write_text('beat', encoding='utf-8')\n"
407
+ )
408
+ child_source = (
409
+ "import subprocess, sys, time\n"
410
+ "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
411
+ "time.sleep(60)\n"
412
+ )
413
+ child = subprocess.Popen(
414
+ [sys.executable, "-c", child_source, grandchild_source, str(heartbeat_file)],
415
+ start_new_session=process_tree_kill.should_start_new_session(),
416
+ )
417
+ group_identifier = _group_led_by(child)
418
+ while not heartbeat_file.exists():
419
+ time.sleep(PROCESS_TREE_EXIT_POLL_SECONDS)
420
+
421
+ terminate_process_tree(child)
422
+ still_running = process_tree_kill.running_process_group_members(
423
+ child, group_identifier
424
+ )
425
+ child.wait()
426
+
427
+ assert still_running == []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.52.0",
3
+ "version": "8.52.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import dataclasses
6
6
  import errno
7
+ import importlib
7
8
  import json
8
9
  import os
9
10
  import shutil
@@ -63,6 +64,16 @@ from dev_env_scripts_constants.codex_account_constants import (
63
64
  WEEKLY_WINDOW_MINUTES,
64
65
  )
65
66
  from dev_env_scripts_constants.shared_tree_constants import CLAUDE_CONFIG_DIR_ENV_VAR
67
+ from shared_tree_paths import resolve_shared_process_tree_scripts_directory
68
+
69
+ _shared_process_tree_scripts_directory = resolve_shared_process_tree_scripts_directory(
70
+ __file__,
71
+ all_environment=os.environ,
72
+ )
73
+ if str(_shared_process_tree_scripts_directory) not in sys.path:
74
+ sys.path.insert(0, str(_shared_process_tree_scripts_directory))
75
+
76
+ _process_tree_kill = importlib.import_module("process_tree_kill")
66
77
 
67
78
 
68
79
  def _read_list(path: Path) -> object:
@@ -492,21 +503,26 @@ def _run_captured_subprocess(all_argv: Sequence[str], **options: object) -> subp
492
503
  errors = str(options.get("errors") or "replace")
493
504
  stdin_bytes = options.get("input")
494
505
  with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
495
- completion = subprocess.run(
506
+ with subprocess.Popen(
496
507
  _resolve_command(all_argv),
497
- input=stdin_bytes,
508
+ stdin=subprocess.PIPE if stdin_bytes is not None else None,
498
509
  stdout=stdout_file,
499
510
  stderr=stderr_file,
500
511
  env=options.get("env"),
501
512
  cwd=options.get("cwd"),
502
- timeout=options.get("timeout"),
503
- check=False,
504
- )
513
+ start_new_session=_process_tree_kill.should_start_new_session(),
514
+ ) as process:
515
+ try:
516
+ process.communicate(input=stdin_bytes, timeout=options.get("timeout"))
517
+ except (subprocess.TimeoutExpired, KeyboardInterrupt):
518
+ _process_tree_kill.terminate_process_tree(process)
519
+ process.wait()
520
+ raise
505
521
  stdout_file.seek(0)
506
522
  stderr_file.seek(0)
507
523
  stdout = stdout_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
508
524
  stderr = stderr_file.read().decode(encoding, errors).replace("\r\n", "\n").replace("\r", "\n")
509
- return subprocess.CompletedProcess(list(all_argv), completion.returncode, stdout, stderr)
525
+ return subprocess.CompletedProcess(list(all_argv), process.returncode, stdout, stderr)
510
526
 
511
527
 
512
528
  subprocess_runner: SubprocessRunner = _run_captured_subprocess
@@ -5,6 +5,8 @@ import json
5
5
  import os
6
6
  import shutil
7
7
  import subprocess
8
+ import sys
9
+ import time
8
10
  from datetime import datetime, timezone
9
11
  from pathlib import Path
10
12
  from types import SimpleNamespace
@@ -222,16 +224,85 @@ def test_should_raise_windows_lock_error_other_than_contention(tmp_path: Path) -
222
224
  assert all_calls == [lock_descriptor]
223
225
 
224
226
 
225
- def test_should_launch_the_path_resolved_command_file(monkeypatch: pytest.MonkeyPatch) -> None:
226
- resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
227
- monkeypatch.setattr(shutil, "which", lambda name: resolved_command if name == "claude" else None)
227
+ GRANDCHILD_HEARTBEAT_SCRIPT = (
228
+ "import pathlib, sys, time\n"
229
+ "heartbeat = pathlib.Path(sys.argv[1])\n"
230
+ "for each_beat in range(300):\n"
231
+ " heartbeat.write_text(str(each_beat), encoding='utf-8')\n"
232
+ " time.sleep(0.1)\n"
233
+ )
234
+
235
+ CHILD_SCRIPT = (
236
+ "import subprocess, sys, time\n"
237
+ "subprocess.Popen([sys.executable, '-c', sys.argv[1], sys.argv[2]])\n"
238
+ "time.sleep(60)\n"
239
+ )
240
+
241
+
242
+ def test_should_end_grandchildren_when_the_job_times_out(tmp_path: Path) -> None:
243
+ heartbeat_file = tmp_path / "heartbeat.txt"
244
+
245
+ with pytest.raises(subprocess.TimeoutExpired):
246
+ support._run_captured_subprocess(
247
+ [sys.executable, "-c", CHILD_SCRIPT, GRANDCHILD_HEARTBEAT_SCRIPT, str(heartbeat_file)],
248
+ timeout=5,
249
+ )
250
+
251
+ beat_after_timeout = heartbeat_file.read_text(encoding="utf-8")
252
+ time.sleep(0.5)
253
+ assert heartbeat_file.read_text(encoding="utf-8") == beat_after_timeout
254
+
255
+
256
+ def test_should_end_grandchildren_when_the_broker_is_interrupted(
257
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
258
+ ) -> None:
259
+ heartbeat_file = tmp_path / "heartbeat.txt"
260
+ original_communicate = subprocess.Popen.communicate
261
+
262
+ def interrupted_communicate(process: subprocess.Popen[bytes], *args: object, **kwargs: object) -> object:
263
+ try:
264
+ return original_communicate(process, *args, timeout=5)
265
+ except subprocess.TimeoutExpired:
266
+ raise KeyboardInterrupt from None
267
+
268
+ monkeypatch.setattr(subprocess.Popen, "communicate", interrupted_communicate)
269
+
270
+ with pytest.raises(KeyboardInterrupt):
271
+ support._run_captured_subprocess(
272
+ [sys.executable, "-c", CHILD_SCRIPT, GRANDCHILD_HEARTBEAT_SCRIPT, str(heartbeat_file)],
273
+ )
274
+
275
+ beat_after_interrupt = heartbeat_file.read_text(encoding="utf-8")
276
+ time.sleep(0.5)
277
+ assert heartbeat_file.read_text(encoding="utf-8") == beat_after_interrupt
278
+
279
+
280
+ def _record_launches(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
228
281
  all_launched_argv: list[list[str]] = []
229
282
 
230
- def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
231
- all_launched_argv.append(list(all_argv))
232
- return subprocess.CompletedProcess(all_argv, 0)
283
+ class FakeProcess:
284
+ returncode = 0
285
+
286
+ def __init__(self, all_argv: list[str], **options: object) -> None:
287
+ all_launched_argv.append(list(all_argv))
288
+
289
+ def __enter__(self) -> "FakeProcess":
290
+ return self
291
+
292
+ def __exit__(self, *exception_info: object) -> None:
293
+ return None
233
294
 
234
- monkeypatch.setattr(support.subprocess, "run", fake_run)
295
+ def communicate(self, **options: object) -> tuple[None, None]:
296
+ return None, None
297
+
298
+ monkeypatch.setattr(support.subprocess, "Popen", FakeProcess)
299
+ return all_launched_argv
300
+
301
+
302
+ def test_should_launch_the_path_resolved_command_file(monkeypatch: pytest.MonkeyPatch) -> None:
303
+ resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
304
+ monkeypatch.setattr(shutil, "which", lambda name: resolved_command if name == "claude" else None)
305
+ all_launched_argv = _record_launches(monkeypatch)
235
306
 
236
307
  completion = support.subprocess_runner(["claude", "-p"], input=b"", encoding="utf-8", errors="replace")
237
308
 
@@ -241,7 +312,7 @@ def test_should_launch_the_path_resolved_command_file(monkeypatch: pytest.Monkey
241
312
 
242
313
  def test_should_refuse_to_launch_a_bare_command_missing_from_path(monkeypatch: pytest.MonkeyPatch) -> None:
243
314
  monkeypatch.setattr(shutil, "which", lambda name: None)
244
- monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a missing command ran"))
315
+ monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a missing command ran"))
245
316
 
246
317
  with pytest.raises(FileNotFoundError) as raised:
247
318
  support.subprocess_runner(["claude", "-p"])
@@ -256,7 +327,7 @@ def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
256
327
  ) -> None:
257
328
  resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
258
329
  monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
259
- monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
330
+ monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a batch file ran"))
260
331
 
261
332
  with pytest.raises(OSError) as raised:
262
333
  support.subprocess_runner(["claude", "-p", f"fix{metacharacter}test"])
@@ -269,7 +340,7 @@ def test_should_refuse_a_batch_file_argument_that_cmd_would_parse(
269
340
  def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch: pytest.MonkeyPatch) -> None:
270
341
  resolved_command = r"C:\tools\CLAUDE.BAT"
271
342
  monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
272
- monkeypatch.setattr(support.subprocess, "run", lambda *arguments, **options: pytest.fail("a batch file ran"))
343
+ monkeypatch.setattr(support.subprocess, "Popen", lambda *arguments, **options: pytest.fail("a batch file ran"))
273
344
 
274
345
  with pytest.raises(OSError) as raised:
275
346
  support.subprocess_runner(["claude", "a & b"])
@@ -280,13 +351,7 @@ def test_should_refuse_metacharacters_for_an_uppercase_bat_extension(monkeypatch
280
351
  def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPatch) -> None:
281
352
  resolved_command = r"C:\Users\someone\AppData\Roaming\npm\claude.cmd"
282
353
  monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
283
- all_launched_argv: list[list[str]] = []
284
-
285
- def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
286
- all_launched_argv.append(list(all_argv))
287
- return subprocess.CompletedProcess(all_argv, 0)
288
-
289
- monkeypatch.setattr(support.subprocess, "run", fake_run)
354
+ all_launched_argv = _record_launches(monkeypatch)
290
355
 
291
356
  support.subprocess_runner(["claude", "-p", "--output-format", "json", "--model", "opus"], input=b"a & b")
292
357
 
@@ -296,13 +361,7 @@ def test_should_launch_a_batch_file_with_flags_only(monkeypatch: pytest.MonkeyPa
296
361
  def test_should_pass_metacharacters_to_an_executable_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
297
362
  resolved_command = "/usr/local/bin/claude"
298
363
  monkeypatch.setattr(shutil, "which", lambda name: resolved_command)
299
- all_launched_argv: list[list[str]] = []
300
-
301
- def fake_run(all_argv: list[str], **options: object) -> subprocess.CompletedProcess[bytes]:
302
- all_launched_argv.append(list(all_argv))
303
- return subprocess.CompletedProcess(all_argv, 0)
304
-
305
- monkeypatch.setattr(support.subprocess, "run", fake_run)
364
+ all_launched_argv = _record_launches(monkeypatch)
306
365
 
307
366
  support.subprocess_runner(["claude", "-p", 'say "a & b" | 100%!'])
308
367