diff --git a/iticli/cli.py b/iticli/cli.py index 4328c3f..12550e3 100644 --- a/iticli/cli.py +++ b/iticli/cli.py @@ -560,8 +560,31 @@ def update_self() -> int: def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int: env = project_env(cwd, extra_env) - completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False) - return int(completed.returncode) + process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env) + try: + return int(process.wait()) + except KeyboardInterrupt: + _wait_after_keyboard_interrupt(process) + return 130 + + +def _wait_after_keyboard_interrupt(process: subprocess.Popen) -> None: + try: + process.wait(timeout=30) + return + except KeyboardInterrupt: + process.terminate() + except subprocess.TimeoutExpired: + process.terminate() + + try: + process.wait(timeout=5) + except KeyboardInterrupt: + process.kill() + process.wait() + except subprocess.TimeoutExpired: + process.kill() + process.wait() def project_env(root: Path, extra_env: dict[str, str] | None = None) -> dict[str, str]: diff --git a/tests/test_cli.py b/tests/test_cli.py index 1f5415c..28a5dda 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import subprocess import pytest @@ -22,6 +23,7 @@ from iticli.cli import ( parse_run_args, resolve_worker_module, resolve_update_packages, + run, update_sync_cmd, update_pyproject_git_tags, version_is_newer, @@ -33,6 +35,71 @@ def write(path: Path, text: str) -> None: path.write_text(text, encoding="utf-8") +def test_run_returns_130_after_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class DummyProcess: + def __init__(self) -> None: + self.wait_calls: list[int | None] = [] + + def wait(self, timeout: int | None = None) -> int: + self.wait_calls.append(timeout) + if timeout is None: + raise KeyboardInterrupt + return 0 + + def terminate(self) -> None: + raise AssertionError("terminate should not be called after graceful child exit") + + def kill(self) -> None: + raise AssertionError("kill should not be called after graceful child exit") + + process = DummyProcess() + + def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess: + assert cmd == ["demo"] + assert cwd == str(tmp_path) + assert env["PYTHONPATH"] + return process + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + assert run(["demo"], tmp_path, extra_env={"PYTHONPATH": "."}) == 130 + assert process.wait_calls == [None, 30] + + +def test_run_terminates_child_when_interrupt_shutdown_times_out( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class DummyProcess: + def __init__(self) -> None: + self.terminated = False + self.killed = False + + def wait(self, timeout: int | None = None) -> int: + if timeout is None: + raise KeyboardInterrupt + if timeout == 30: + raise subprocess.TimeoutExpired(["demo"], timeout) + return 0 + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + process = DummyProcess() + + def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess: + return process + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + assert run(["demo"], tmp_path) == 130 + assert process.terminated is True + assert process.killed is False + + def test_detect_framework_project(tmp_path: Path) -> None: write( tmp_path / "pyproject.toml",