timeout-dead 0.1.1__tar.gz → 0.2.0__tar.gz
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.
- timeout_dead-0.2.0/.vscode/settings.json +8 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/AGENTS.md +25 -13
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/PKG-INFO +41 -8
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/README.md +40 -7
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/_version.py +3 -3
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/cli.py +49 -25
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead.egg-info/SOURCES.txt +1 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/tests/conftest.py +1 -1
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/tests/test_cli.py +92 -7
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/publish.yml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/pyright.yml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/ruff.yml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.github/workflows/tests.yml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/.gitignore +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/CODE-STYLE.md +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/CONTRIBUTING.md +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/LICENSE +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/MANIFEST.in +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/icon.png +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/pyproject.toml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/ruff.toml +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/setup.cfg +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/src/timeout_dead/__init__.py +0 -0
- {timeout_dead-0.1.1 → timeout_dead-0.2.0}/uv.lock +0 -0
|
@@ -37,20 +37,32 @@ If the user asks "what should the commit message be?" — **suggest a message bu
|
|
|
37
37
|
|
|
38
38
|
## Workflow & Verification Commands
|
|
39
39
|
|
|
40
|
+
### Setup
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv tool install timeout-dead
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Если уже установлена — убедись что работает:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
time-d -h
|
|
50
|
+
```
|
|
51
|
+
|
|
40
52
|
### Time limit (HARD REQUIREMENT)
|
|
41
53
|
|
|
42
54
|
**Every bash command MUST complete within 60 seconds.** All commands must be invoked through the timeout wrapper:
|
|
43
55
|
|
|
44
56
|
```bash
|
|
45
|
-
|
|
57
|
+
time-d --sec 60 "<your command>"
|
|
46
58
|
```
|
|
47
59
|
|
|
48
60
|
Long-running daemons must use `nohup ... >/dev/null 2>&1 &` so the wrapper returns immediately.
|
|
49
61
|
|
|
50
|
-
###
|
|
62
|
+
### Install dependencies
|
|
51
63
|
|
|
52
64
|
```bash
|
|
53
|
-
|
|
65
|
+
time-d --sec 60 "uv sync"
|
|
54
66
|
```
|
|
55
67
|
|
|
56
68
|
### Verify after changes
|
|
@@ -58,16 +70,16 @@ timeout-dead --sec 60 "uv sync"
|
|
|
58
70
|
Run **all** checks in this order — treat errors as blockers:
|
|
59
71
|
|
|
60
72
|
```bash
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
time-d --sec 60 "ruff check ."
|
|
74
|
+
time-d --sec 60 "ruff format --check ."
|
|
75
|
+
time-d --sec 60 "pyright ."
|
|
76
|
+
time-d --sec 60 "python -m pytest tests/ -v"
|
|
65
77
|
```
|
|
66
78
|
|
|
67
79
|
### Fix formatting & imports
|
|
68
80
|
|
|
69
81
|
```bash
|
|
70
|
-
|
|
82
|
+
time-d --sec 60 "ruff check --fix . && ruff format ."
|
|
71
83
|
```
|
|
72
84
|
|
|
73
85
|
**LSP is mandatory.** Configure `pyright-langserver` and `ruff server` in your editor. After every change, confirm lint, format, and type-check show **0 errors**. `ruff format` is the single source of truth for formatting — no `black`, no `isort`.
|
|
@@ -75,7 +87,7 @@ timeout-dead --sec 60 "ruff check --fix . && ruff format ."
|
|
|
75
87
|
### Run a single test
|
|
76
88
|
|
|
77
89
|
```bash
|
|
78
|
-
|
|
90
|
+
time-d --sec 60 "python -m pytest tests/test_file.py::test_name -v"
|
|
79
91
|
```
|
|
80
92
|
|
|
81
93
|
### Mandatory testing
|
|
@@ -170,19 +182,19 @@ class ConflictError(BaseError):
|
|
|
170
182
|
### Unit Tests
|
|
171
183
|
|
|
172
184
|
- Tests live in `tests/` mirroring the source structure.
|
|
173
|
-
- Run unit tests: `
|
|
185
|
+
- Run unit tests: `time-d --sec 60 "pytest tests/ -v --ignore=tests/integration"`.
|
|
174
186
|
|
|
175
187
|
### Integration Tests
|
|
176
188
|
|
|
177
189
|
- Place integration tests in `tests/integration/`. Use `@pytest.mark.integration` marker.
|
|
178
|
-
- Run integration tests: `
|
|
179
|
-
- Run unit tests only: `
|
|
190
|
+
- Run integration tests: `time-d --sec 60 "pytest tests/integration/ -v -m \"integration\""`.
|
|
191
|
+
- Run unit tests only: `time-d --sec 60 "pytest tests/ -v -m \"not integration\""`.
|
|
180
192
|
|
|
181
193
|
### Coverage
|
|
182
194
|
|
|
183
195
|
- Aim for high coverage of core business logic. Use `pytest-cov` to measure:
|
|
184
196
|
```bash
|
|
185
|
-
|
|
197
|
+
time-d --sec 60 "pytest tests/ --cov=src/timeout_dead --cov-report=term-missing"
|
|
186
198
|
```
|
|
187
199
|
|
|
188
200
|
## Environment & Configuration
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: timeout-dead
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight command timeout utility with zero runtime dependencies
|
|
5
5
|
Author-email: Dmitry Krivoruchko <umbrella.leaf.for.work@gmail.com>
|
|
6
6
|
License-Expression: Unlicense
|
|
@@ -67,6 +67,9 @@ time-d "echo hello"
|
|
|
67
67
|
# Specify a custom timeout
|
|
68
68
|
timeout-dead --sec 120 "npm run build"
|
|
69
69
|
|
|
70
|
+
# Sub-second timeouts work too
|
|
71
|
+
timeout-dead --sec 0.5 "potentially-hanging-tool"
|
|
72
|
+
|
|
70
73
|
# Use SIGINT instead of default SIGTERM
|
|
71
74
|
timeout-dead --signal INT --sec 30 "long-running-server"
|
|
72
75
|
|
|
@@ -77,7 +80,7 @@ timeout-dead --no-output "curl -s https://example.com"
|
|
|
77
80
|
## Usage
|
|
78
81
|
|
|
79
82
|
```
|
|
80
|
-
usage: timeout-dead [-h] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
|
|
83
|
+
usage: timeout-dead [-h] [-v] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
|
|
81
84
|
|
|
82
85
|
Lightweight command timeout utility.
|
|
83
86
|
|
|
@@ -86,7 +89,8 @@ positional arguments:
|
|
|
86
89
|
|
|
87
90
|
options:
|
|
88
91
|
-h, --help show this help message and exit
|
|
89
|
-
--
|
|
92
|
+
-v, --version show version and exit
|
|
93
|
+
--sec SECONDS timeout in seconds (default: 60.0, accepts floats)
|
|
90
94
|
--signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
|
|
91
95
|
--no-output suppress normal output (stdout, stderr, header, footer)
|
|
92
96
|
```
|
|
@@ -103,12 +107,41 @@ options:
|
|
|
103
107
|
|
|
104
108
|
## Signal reference
|
|
105
109
|
|
|
106
|
-
| Signal | Unix
|
|
107
|
-
|
|
110
|
+
| Signal | Unix | Windows |
|
|
111
|
+
| ------ | ------------------------------------- | ---------------------------------- |
|
|
108
112
|
| `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
|
|
109
|
-
| `KILL` | `SIGKILL` (9) — force kill
|
|
110
|
-
| `HUP` | `SIGHUP` (1) — hangup
|
|
111
|
-
| `INT` | `SIGINT` (2) — interrupt (Ctrl+C)
|
|
113
|
+
| `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
|
|
114
|
+
| `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
|
|
115
|
+
| `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
|
|
116
|
+
|
|
117
|
+
## Why `subprocess` timeout is not enough
|
|
118
|
+
|
|
119
|
+
Python's built-in `subprocess` timeout only kills the direct child, not its entire process tree. If your command spawns subprocesses (`npm install`, `make`, `docker build`), children survive the parent kill.
|
|
120
|
+
|
|
121
|
+
`timeout-dead` uses **process groups** to terminate everything — every subprocess, pipeline, and child.
|
|
122
|
+
|
|
123
|
+
## Real-world scenarios
|
|
124
|
+
|
|
125
|
+
| Scenario | Command | Why it hangs | `timeout-dead` |
|
|
126
|
+
| ---------------- | ------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
|
|
127
|
+
| Gradle build | `./gradlew build` | 20+ min fresh build, agent generates duplicate commands | `timeout-dead --sec 600 "./gradlew build"` |
|
|
128
|
+
| CMake build | `cmake --build .` | Locks waiting for dependency resolution | `timeout-dead --sec 180 "cmake --build ."` |
|
|
129
|
+
| Spring Boot | `./gradlew bootRun` | Server never exits, agent won't proceed | `timeout-dead --sec 30 --signal INT "./gradlew bootRun"` |
|
|
130
|
+
| Docker build | `docker build -t myapp .` | Network timeout, internal retries, no progress | `timeout-dead --sec 600 "docker build -t myapp ."` |
|
|
131
|
+
| npm install | `npm install` | Corrupted cache or registry auth hang | `timeout-dead --sec 300 "npm install"` |
|
|
132
|
+
| Interactive REPL | `python` / `node` / `irb` | Waits for input, agent doesn't know | `timeout-dead --sec 5 "python"` |
|
|
133
|
+
|
|
134
|
+
## For AI agents
|
|
135
|
+
|
|
136
|
+
If you build agents that execute shell commands, `timeout-dead` is essential infrastructure. Agents frequently generate commands that hang — waiting for input, entering infinite loops, or starting interactive programs.
|
|
137
|
+
|
|
138
|
+
Instead of agents freezing indefinitely, wrap every command:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
timeout-dead --sec <timeout> --signal <signal> "<command>"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
No dependencies, no code changes. Agent always gets a response — exit code + output — and can implement retry, fallback, or user notification.
|
|
112
145
|
|
|
113
146
|
## Development
|
|
114
147
|
|
|
@@ -41,6 +41,9 @@ time-d "echo hello"
|
|
|
41
41
|
# Specify a custom timeout
|
|
42
42
|
timeout-dead --sec 120 "npm run build"
|
|
43
43
|
|
|
44
|
+
# Sub-second timeouts work too
|
|
45
|
+
timeout-dead --sec 0.5 "potentially-hanging-tool"
|
|
46
|
+
|
|
44
47
|
# Use SIGINT instead of default SIGTERM
|
|
45
48
|
timeout-dead --signal INT --sec 30 "long-running-server"
|
|
46
49
|
|
|
@@ -51,7 +54,7 @@ timeout-dead --no-output "curl -s https://example.com"
|
|
|
51
54
|
## Usage
|
|
52
55
|
|
|
53
56
|
```
|
|
54
|
-
usage: timeout-dead [-h] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
|
|
57
|
+
usage: timeout-dead [-h] [-v] [--sec SECONDS] [--signal SIGNAL] [--no-output] COMMAND ...
|
|
55
58
|
|
|
56
59
|
Lightweight command timeout utility.
|
|
57
60
|
|
|
@@ -60,7 +63,8 @@ positional arguments:
|
|
|
60
63
|
|
|
61
64
|
options:
|
|
62
65
|
-h, --help show this help message and exit
|
|
63
|
-
--
|
|
66
|
+
-v, --version show version and exit
|
|
67
|
+
--sec SECONDS timeout in seconds (default: 60.0, accepts floats)
|
|
64
68
|
--signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
|
|
65
69
|
--no-output suppress normal output (stdout, stderr, header, footer)
|
|
66
70
|
```
|
|
@@ -77,12 +81,41 @@ options:
|
|
|
77
81
|
|
|
78
82
|
## Signal reference
|
|
79
83
|
|
|
80
|
-
| Signal | Unix
|
|
81
|
-
|
|
84
|
+
| Signal | Unix | Windows |
|
|
85
|
+
| ------ | ------------------------------------- | ---------------------------------- |
|
|
82
86
|
| `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
|
|
83
|
-
| `KILL` | `SIGKILL` (9) — force kill
|
|
84
|
-
| `HUP` | `SIGHUP` (1) — hangup
|
|
85
|
-
| `INT` | `SIGINT` (2) — interrupt (Ctrl+C)
|
|
87
|
+
| `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
|
|
88
|
+
| `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
|
|
89
|
+
| `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
|
|
90
|
+
|
|
91
|
+
## Why `subprocess` timeout is not enough
|
|
92
|
+
|
|
93
|
+
Python's built-in `subprocess` timeout only kills the direct child, not its entire process tree. If your command spawns subprocesses (`npm install`, `make`, `docker build`), children survive the parent kill.
|
|
94
|
+
|
|
95
|
+
`timeout-dead` uses **process groups** to terminate everything — every subprocess, pipeline, and child.
|
|
96
|
+
|
|
97
|
+
## Real-world scenarios
|
|
98
|
+
|
|
99
|
+
| Scenario | Command | Why it hangs | `timeout-dead` |
|
|
100
|
+
| ---------------- | ------------------------- | ------------------------------------------------------- | -------------------------------------------------------- |
|
|
101
|
+
| Gradle build | `./gradlew build` | 20+ min fresh build, agent generates duplicate commands | `timeout-dead --sec 600 "./gradlew build"` |
|
|
102
|
+
| CMake build | `cmake --build .` | Locks waiting for dependency resolution | `timeout-dead --sec 180 "cmake --build ."` |
|
|
103
|
+
| Spring Boot | `./gradlew bootRun` | Server never exits, agent won't proceed | `timeout-dead --sec 30 --signal INT "./gradlew bootRun"` |
|
|
104
|
+
| Docker build | `docker build -t myapp .` | Network timeout, internal retries, no progress | `timeout-dead --sec 600 "docker build -t myapp ."` |
|
|
105
|
+
| npm install | `npm install` | Corrupted cache or registry auth hang | `timeout-dead --sec 300 "npm install"` |
|
|
106
|
+
| Interactive REPL | `python` / `node` / `irb` | Waits for input, agent doesn't know | `timeout-dead --sec 5 "python"` |
|
|
107
|
+
|
|
108
|
+
## For AI agents
|
|
109
|
+
|
|
110
|
+
If you build agents that execute shell commands, `timeout-dead` is essential infrastructure. Agents frequently generate commands that hang — waiting for input, entering infinite loops, or starting interactive programs.
|
|
111
|
+
|
|
112
|
+
Instead of agents freezing indefinitely, wrap every command:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
timeout-dead --sec <timeout> --signal <signal> "<command>"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
No dependencies, no code changes. Agent always gets a response — exit code + output — and can implement retry, fallback, or user notification.
|
|
86
119
|
|
|
87
120
|
## Development
|
|
88
121
|
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gd1dae1c5b'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
|
|
3
|
-
"""
|
|
3
|
+
"""Lightweight command timeout utility."""
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
@@ -10,6 +10,8 @@ import subprocess
|
|
|
10
10
|
import sys
|
|
11
11
|
import threading
|
|
12
12
|
import time
|
|
13
|
+
from importlib.metadata import PackageNotFoundError
|
|
14
|
+
from importlib.metadata import version as _get_version
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
# MARK: Constants
|
|
@@ -17,13 +19,13 @@ import time
|
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class _Const:
|
|
20
|
-
DEFAULT_TIMEOUT_S:
|
|
22
|
+
DEFAULT_TIMEOUT_S: float = 60.0
|
|
21
23
|
GRACE_PERIOD_S: float = 1.0
|
|
22
|
-
HEADER_SEPARATOR: str = "-" *
|
|
24
|
+
HEADER_SEPARATOR: str = "-" * 50
|
|
23
25
|
|
|
24
26
|
MSG_NO_COMMAND: str = "Error: no command specified"
|
|
25
|
-
MSG_BASH_NOT_FOUND: str = "bash not found in PATH
|
|
26
|
-
MSG_TIMEOUT: str = "Timeout exceeded {}
|
|
27
|
+
MSG_BASH_NOT_FOUND: str = "bash not found in PATH"
|
|
28
|
+
MSG_TIMEOUT: str = "Timeout exceeded {}s"
|
|
27
29
|
MSG_EXEC_ERROR: str = "Execution error: {}"
|
|
28
30
|
|
|
29
31
|
SIGNAL_NAMES: tuple[str, ...] = ("TERM", "KILL", "HUP", "INT")
|
|
@@ -35,6 +37,17 @@ class _Const:
|
|
|
35
37
|
"INT": signal.SIGINT,
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _resolve_version() -> str:
|
|
42
|
+
try:
|
|
43
|
+
return _get_version("timeout-dead")
|
|
44
|
+
|
|
45
|
+
except PackageNotFoundError:
|
|
46
|
+
return "unknown"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PROJECT_VERSION: str = _Const._resolve_version()
|
|
50
|
+
|
|
38
51
|
|
|
39
52
|
# MARK: Private Helpers
|
|
40
53
|
# ------------------------------------------------
|
|
@@ -49,13 +62,13 @@ def _is_windows() -> bool:
|
|
|
49
62
|
|
|
50
63
|
def _find_bash() -> str:
|
|
51
64
|
"""
|
|
52
|
-
|
|
65
|
+
Locate bash executable in PATH.
|
|
53
66
|
|
|
54
67
|
Returns:
|
|
55
|
-
str:
|
|
68
|
+
str: path to the bash executable
|
|
56
69
|
|
|
57
70
|
Raises:
|
|
58
|
-
SystemExit:
|
|
71
|
+
SystemExit: if bash is not found
|
|
59
72
|
"""
|
|
60
73
|
|
|
61
74
|
bash_path = shutil.which("bash")
|
|
@@ -77,7 +90,7 @@ def _terminate_process(
|
|
|
77
90
|
force: bool = False,
|
|
78
91
|
signal_num: int = signal.SIGTERM,
|
|
79
92
|
) -> None:
|
|
80
|
-
"""
|
|
93
|
+
"""Terminate the process — gracefully (chosen signal) or forcefully."""
|
|
81
94
|
|
|
82
95
|
if process.poll() is not None:
|
|
83
96
|
return
|
|
@@ -112,10 +125,10 @@ def _terminate_process(
|
|
|
112
125
|
|
|
113
126
|
def _kill_with_timeout(
|
|
114
127
|
process: subprocess.Popen[bytes] | subprocess.Popen[str],
|
|
115
|
-
timeout:
|
|
128
|
+
timeout: float,
|
|
116
129
|
signal_num: int = signal.SIGTERM,
|
|
117
130
|
) -> None:
|
|
118
|
-
"""
|
|
131
|
+
"""Kill the process after timeout with two-stage logic."""
|
|
119
132
|
|
|
120
133
|
if process.poll() is not None:
|
|
121
134
|
return
|
|
@@ -135,21 +148,21 @@ def _kill_with_timeout(
|
|
|
135
148
|
|
|
136
149
|
def run_command(
|
|
137
150
|
command_string: str,
|
|
138
|
-
timeout:
|
|
151
|
+
timeout: float = _Const.DEFAULT_TIMEOUT_S,
|
|
139
152
|
signal_name: str = "TERM",
|
|
140
153
|
no_output: bool = False,
|
|
141
154
|
) -> int:
|
|
142
155
|
"""
|
|
143
|
-
|
|
156
|
+
Run a command with timeout via bash.
|
|
144
157
|
|
|
145
158
|
Args:
|
|
146
|
-
command_string (str):
|
|
147
|
-
timeout (
|
|
148
|
-
signal_name (str):
|
|
149
|
-
no_output (bool):
|
|
159
|
+
command_string (str): command to execute
|
|
160
|
+
timeout (float): timeout in seconds
|
|
161
|
+
signal_name (str): signal name for graceful termination
|
|
162
|
+
no_output (bool): suppress normal output
|
|
150
163
|
|
|
151
164
|
Returns:
|
|
152
|
-
int:
|
|
165
|
+
int: process return code (-1 on launch error)
|
|
153
166
|
"""
|
|
154
167
|
|
|
155
168
|
signal_num = _Const.SIGNAL_MAP.get(signal_name, signal.SIGTERM)
|
|
@@ -157,7 +170,10 @@ def run_command(
|
|
|
157
170
|
timer: threading.Timer | None = None
|
|
158
171
|
|
|
159
172
|
try:
|
|
160
|
-
creationflags =
|
|
173
|
+
creationflags = (
|
|
174
|
+
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if _is_windows() else 0
|
|
175
|
+
)
|
|
176
|
+
|
|
161
177
|
start_new_session = not _is_windows()
|
|
162
178
|
|
|
163
179
|
process = subprocess.Popen(
|
|
@@ -190,6 +206,7 @@ def run_command(
|
|
|
190
206
|
except Exception as e:
|
|
191
207
|
if timer:
|
|
192
208
|
timer.cancel()
|
|
209
|
+
|
|
193
210
|
if process and process.poll() is None:
|
|
194
211
|
try:
|
|
195
212
|
_terminate_process(process, force=True)
|
|
@@ -206,16 +223,23 @@ def run_command(
|
|
|
206
223
|
|
|
207
224
|
|
|
208
225
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
209
|
-
"""
|
|
226
|
+
"""Parse command-line arguments."""
|
|
210
227
|
|
|
211
228
|
parser = argparse.ArgumentParser(
|
|
212
229
|
description="Lightweight command timeout utility.",
|
|
213
230
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
214
231
|
)
|
|
215
232
|
|
|
233
|
+
parser.add_argument(
|
|
234
|
+
"-v",
|
|
235
|
+
"--version",
|
|
236
|
+
action="version",
|
|
237
|
+
version=f"timeout-dead {_PROJECT_VERSION}",
|
|
238
|
+
)
|
|
239
|
+
|
|
216
240
|
parser.add_argument(
|
|
217
241
|
"--sec",
|
|
218
|
-
type=
|
|
242
|
+
type=float,
|
|
219
243
|
default=_Const.DEFAULT_TIMEOUT_S,
|
|
220
244
|
help=f"timeout in seconds (default: {_Const.DEFAULT_TIMEOUT_S})",
|
|
221
245
|
metavar="SECONDS",
|
|
@@ -250,8 +274,8 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
250
274
|
# ------------------------------------------------
|
|
251
275
|
|
|
252
276
|
|
|
253
|
-
def print_header(command: str, timeout:
|
|
254
|
-
"""
|
|
277
|
+
def print_header(command: str, timeout: float) -> None:
|
|
278
|
+
"""Print execution header."""
|
|
255
279
|
|
|
256
280
|
print(f"Running: {command}")
|
|
257
281
|
print(f"Timeout: {timeout} seconds")
|
|
@@ -262,7 +286,7 @@ def print_header(command: str, timeout: int) -> None:
|
|
|
262
286
|
|
|
263
287
|
|
|
264
288
|
def print_footer(return_code: int) -> None:
|
|
265
|
-
"""
|
|
289
|
+
"""Print execution footer."""
|
|
266
290
|
|
|
267
291
|
print(_Const.HEADER_SEPARATOR)
|
|
268
292
|
print(f"Exit code: {return_code}")
|
|
@@ -272,7 +296,7 @@ def print_footer(return_code: int) -> None:
|
|
|
272
296
|
|
|
273
297
|
|
|
274
298
|
def main(argv: list[str] | None = None) -> None:
|
|
275
|
-
"""
|
|
299
|
+
"""Main entry point."""
|
|
276
300
|
|
|
277
301
|
args = parse_arguments(argv)
|
|
278
302
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Tests for timeout-dead CLI utility."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import signal
|
|
@@ -30,7 +30,7 @@ def _run_cli(
|
|
|
30
30
|
*args: str,
|
|
31
31
|
timeout: int = 10,
|
|
32
32
|
) -> subprocess.CompletedProcess[str]:
|
|
33
|
-
"""
|
|
33
|
+
"""Run timeout-dead as a subprocess."""
|
|
34
34
|
|
|
35
35
|
return subprocess.run(
|
|
36
36
|
[sys.executable, "-m", "timeout_dead.cli", *args],
|
|
@@ -48,7 +48,7 @@ def _run_cli(
|
|
|
48
48
|
class TestParseArguments:
|
|
49
49
|
def test_defaults(self) -> None:
|
|
50
50
|
args = parse_arguments(["echo", "hello"])
|
|
51
|
-
assert args.sec == 60
|
|
51
|
+
assert args.sec == 60.0
|
|
52
52
|
assert args.signal == "TERM"
|
|
53
53
|
assert args.no_output is False
|
|
54
54
|
assert args.command == ["echo", "hello"]
|
|
@@ -57,7 +57,25 @@ class TestParseArguments:
|
|
|
57
57
|
|
|
58
58
|
def test_custom_timeout(self) -> None:
|
|
59
59
|
args = parse_arguments(["--sec", "120", "echo", "hello"])
|
|
60
|
-
assert args.sec == 120
|
|
60
|
+
assert args.sec == 120.0
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def test_float_timeout(self) -> None:
|
|
65
|
+
args = parse_arguments(["--sec", "2.5", "echo", "hello"])
|
|
66
|
+
assert args.sec == 2.5
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def test_subsecond_timeout(self) -> None:
|
|
71
|
+
args = parse_arguments(["--sec", "0.3", "sleep", "1"])
|
|
72
|
+
assert args.sec == 0.3
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def test_zero_timeout(self) -> None:
|
|
77
|
+
args = parse_arguments(["--sec", "0", "echo", "hello"])
|
|
78
|
+
assert args.sec == 0.0
|
|
61
79
|
|
|
62
80
|
# ------------------------------------------------
|
|
63
81
|
|
|
@@ -217,6 +235,58 @@ class TestRunCommand:
|
|
|
217
235
|
assert rc != 0
|
|
218
236
|
|
|
219
237
|
|
|
238
|
+
# MARK: Float timeout tests
|
|
239
|
+
# ------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class TestFloatTimeout:
|
|
243
|
+
def test_float_parsed_correctly(self) -> None:
|
|
244
|
+
rc = run_command("echo ok", timeout=2.5)
|
|
245
|
+
assert rc == 0
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def test_subsecond_timeout_triggers(self) -> None:
|
|
250
|
+
rc = run_command("sleep 10", timeout=0.3)
|
|
251
|
+
assert rc != 0
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def test_millisecond_timeout(self) -> None:
|
|
256
|
+
start = time.monotonic()
|
|
257
|
+
rc = run_command("sleep 10", timeout=0.5)
|
|
258
|
+
elapsed = time.monotonic() - start
|
|
259
|
+
assert rc != 0
|
|
260
|
+
assert elapsed < 4.0
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def test_float_in_message(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
265
|
+
run_command("sleep 10", timeout=1.5)
|
|
266
|
+
captured = capsys.readouterr()
|
|
267
|
+
assert "Timeout exceeded" in captured.err
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def test_cli_float_arg(self) -> None:
|
|
272
|
+
result = _run_cli("--sec", "0.3", "sleep", "10")
|
|
273
|
+
assert result.returncode != 0
|
|
274
|
+
assert "Timeout exceeded" in result.stderr
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------
|
|
277
|
+
|
|
278
|
+
def test_cli_float_with_signal(self) -> None:
|
|
279
|
+
result = _run_cli("--sec", "0.5", "--signal", "KILL", "sleep", "10")
|
|
280
|
+
assert result.returncode != 0
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------
|
|
283
|
+
|
|
284
|
+
def test_cli_float_no_output(self) -> None:
|
|
285
|
+
result = _run_cli("--no-output", "--sec", "0.3", "sleep", "10")
|
|
286
|
+
assert "Timeout exceeded" in result.stderr
|
|
287
|
+
assert "Running:" not in result.stdout
|
|
288
|
+
|
|
289
|
+
|
|
220
290
|
# MARK: Signal selection tests
|
|
221
291
|
# ------------------------------------------------
|
|
222
292
|
|
|
@@ -245,7 +315,7 @@ class TestSignalSelection:
|
|
|
245
315
|
reason="SIGINT file-based test requires Unix signal handling",
|
|
246
316
|
)
|
|
247
317
|
def test_signal_int_handling(self, tmp_path: Path) -> None:
|
|
248
|
-
"""Python
|
|
318
|
+
"""Python script catches SIGINT and writes signal number to file."""
|
|
249
319
|
|
|
250
320
|
marker = tmp_path / "signal.txt"
|
|
251
321
|
script_path = tmp_path / "handler.py"
|
|
@@ -395,6 +465,21 @@ class TestIntegration:
|
|
|
395
465
|
assert "--sec" in result.stdout
|
|
396
466
|
assert "--signal" in result.stdout
|
|
397
467
|
assert "--no-output" in result.stdout
|
|
468
|
+
assert "--version" in result.stdout
|
|
469
|
+
|
|
470
|
+
# ------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def test_cli_version_long(self) -> None:
|
|
473
|
+
result = _run_cli("--version")
|
|
474
|
+
assert result.returncode == 0
|
|
475
|
+
assert "timeout-dead" in result.stdout
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def test_cli_version_short(self) -> None:
|
|
480
|
+
result = _run_cli("-v")
|
|
481
|
+
assert result.returncode == 0
|
|
482
|
+
assert "timeout-dead" in result.stdout
|
|
398
483
|
|
|
399
484
|
# ------------------------------------------------
|
|
400
485
|
|
|
@@ -445,8 +530,8 @@ class TestIntegration:
|
|
|
445
530
|
|
|
446
531
|
class TestConstants:
|
|
447
532
|
def test_default_timeout(self) -> None:
|
|
448
|
-
assert _Const.DEFAULT_TIMEOUT_S == 60
|
|
449
|
-
assert isinstance(_Const.DEFAULT_TIMEOUT_S,
|
|
533
|
+
assert _Const.DEFAULT_TIMEOUT_S == 60.0
|
|
534
|
+
assert isinstance(_Const.DEFAULT_TIMEOUT_S, float)
|
|
450
535
|
|
|
451
536
|
# ------------------------------------------------
|
|
452
537
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|