fix: iticli 优雅处理 Ctrl+C 退出

main
917232558@qq.com 6 days ago
parent 321db4dfd6
commit 193ab7cf16

@ -560,8 +560,31 @@ def update_self() -> int:
def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int: def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int:
env = project_env(cwd, extra_env) env = project_env(cwd, extra_env)
completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False) process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env)
return int(completed.returncode) 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]: def project_env(root: Path, extra_env: dict[str, str] | None = None) -> dict[str, str]:

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
import subprocess
import pytest import pytest
@ -22,6 +23,7 @@ from iticli.cli import (
parse_run_args, parse_run_args,
resolve_worker_module, resolve_worker_module,
resolve_update_packages, resolve_update_packages,
run,
update_sync_cmd, update_sync_cmd,
update_pyproject_git_tags, update_pyproject_git_tags,
version_is_newer, version_is_newer,
@ -33,6 +35,71 @@ def write(path: Path, text: str) -> None:
path.write_text(text, encoding="utf-8") 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: def test_detect_framework_project(tmp_path: Path) -> None:
write( write(
tmp_path / "pyproject.toml", tmp_path / "pyproject.toml",

Loading…
Cancel
Save