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.
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +12 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +93 -6
- package/_shared/process-tree/scripts/test_process_tree_kill.py +149 -0
- package/package.json +1 -1
- package/scripts/account_broker_support.py +22 -6
- package/scripts/test_account_broker_support.py +83 -24
|
@@ -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
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
except
|
|
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
|
@@ -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
|
-
|
|
506
|
+
with subprocess.Popen(
|
|
496
507
|
_resolve_command(all_argv),
|
|
497
|
-
|
|
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
|
-
|
|
503
|
-
|
|
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),
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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, "
|
|
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, "
|
|
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, "
|
|
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
|
|
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
|
|
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
|
|