timeout-dead 0.1.2__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.1.2 → timeout_dead-0.2.0}/PKG-INFO +39 -7
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/README.md +38 -6
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/_version.py +3 -3
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/cli.py +12 -9
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/tests/test_cli.py +74 -4
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/publish.yml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/pyright.yml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/ruff.yml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/tests.yml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.gitignore +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.vscode/settings.json +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/AGENTS.md +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/CODE-STYLE.md +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/CONTRIBUTING.md +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/LICENSE +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/MANIFEST.in +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/icon.png +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/pyproject.toml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/ruff.toml +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/setup.cfg +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/__init__.py +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead.egg-info/SOURCES.txt +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/tests/conftest.py +0 -0
- {timeout_dead-0.1.2 → timeout_dead-0.2.0}/uv.lock +0 -0
|
@@ -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
|
|
|
@@ -87,7 +90,7 @@ positional arguments:
|
|
|
87
90
|
options:
|
|
88
91
|
-h, --help show this help message and exit
|
|
89
92
|
-v, --version show version and exit
|
|
90
|
-
--sec SECONDS timeout in seconds (default: 60)
|
|
93
|
+
--sec SECONDS timeout in seconds (default: 60.0, accepts floats)
|
|
91
94
|
--signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
|
|
92
95
|
--no-output suppress normal output (stdout, stderr, header, footer)
|
|
93
96
|
```
|
|
@@ -104,12 +107,41 @@ options:
|
|
|
104
107
|
|
|
105
108
|
## Signal reference
|
|
106
109
|
|
|
107
|
-
| Signal | Unix
|
|
108
|
-
|
|
110
|
+
| Signal | Unix | Windows |
|
|
111
|
+
| ------ | ------------------------------------- | ---------------------------------- |
|
|
109
112
|
| `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
|
|
110
|
-
| `KILL` | `SIGKILL` (9) — force kill
|
|
111
|
-
| `HUP` | `SIGHUP` (1) — hangup
|
|
112
|
-
| `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.
|
|
113
145
|
|
|
114
146
|
## Development
|
|
115
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
|
|
|
@@ -61,7 +64,7 @@ positional arguments:
|
|
|
61
64
|
options:
|
|
62
65
|
-h, --help show this help message and exit
|
|
63
66
|
-v, --version show version and exit
|
|
64
|
-
--sec SECONDS timeout in seconds (default: 60)
|
|
67
|
+
--sec SECONDS timeout in seconds (default: 60.0, accepts floats)
|
|
65
68
|
--signal SIGNAL signal to send on timeout (TERM, KILL, HUP, INT)
|
|
66
69
|
--no-output suppress normal output (stdout, stderr, header, footer)
|
|
67
70
|
```
|
|
@@ -78,12 +81,41 @@ options:
|
|
|
78
81
|
|
|
79
82
|
## Signal reference
|
|
80
83
|
|
|
81
|
-
| Signal | Unix
|
|
82
|
-
|
|
84
|
+
| Signal | Unix | Windows |
|
|
85
|
+
| ------ | ------------------------------------- | ---------------------------------- |
|
|
83
86
|
| `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
|
|
84
|
-
| `KILL` | `SIGKILL` (9) — force kill
|
|
85
|
-
| `HUP` | `SIGHUP` (1) — hangup
|
|
86
|
-
| `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.
|
|
87
119
|
|
|
88
120
|
## Development
|
|
89
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'
|
|
@@ -19,13 +19,13 @@ from importlib.metadata import version as _get_version
|
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class _Const:
|
|
22
|
-
DEFAULT_TIMEOUT_S:
|
|
22
|
+
DEFAULT_TIMEOUT_S: float = 60.0
|
|
23
23
|
GRACE_PERIOD_S: float = 1.0
|
|
24
|
-
HEADER_SEPARATOR: str = "-" *
|
|
24
|
+
HEADER_SEPARATOR: str = "-" * 50
|
|
25
25
|
|
|
26
26
|
MSG_NO_COMMAND: str = "Error: no command specified"
|
|
27
27
|
MSG_BASH_NOT_FOUND: str = "bash not found in PATH"
|
|
28
|
-
MSG_TIMEOUT: str = "Timeout exceeded {}
|
|
28
|
+
MSG_TIMEOUT: str = "Timeout exceeded {}s"
|
|
29
29
|
MSG_EXEC_ERROR: str = "Execution error: {}"
|
|
30
30
|
|
|
31
31
|
SIGNAL_NAMES: tuple[str, ...] = ("TERM", "KILL", "HUP", "INT")
|
|
@@ -125,7 +125,7 @@ def _terminate_process(
|
|
|
125
125
|
|
|
126
126
|
def _kill_with_timeout(
|
|
127
127
|
process: subprocess.Popen[bytes] | subprocess.Popen[str],
|
|
128
|
-
timeout:
|
|
128
|
+
timeout: float,
|
|
129
129
|
signal_num: int = signal.SIGTERM,
|
|
130
130
|
) -> None:
|
|
131
131
|
"""Kill the process after timeout with two-stage logic."""
|
|
@@ -148,7 +148,7 @@ def _kill_with_timeout(
|
|
|
148
148
|
|
|
149
149
|
def run_command(
|
|
150
150
|
command_string: str,
|
|
151
|
-
timeout:
|
|
151
|
+
timeout: float = _Const.DEFAULT_TIMEOUT_S,
|
|
152
152
|
signal_name: str = "TERM",
|
|
153
153
|
no_output: bool = False,
|
|
154
154
|
) -> int:
|
|
@@ -157,7 +157,7 @@ def run_command(
|
|
|
157
157
|
|
|
158
158
|
Args:
|
|
159
159
|
command_string (str): command to execute
|
|
160
|
-
timeout (
|
|
160
|
+
timeout (float): timeout in seconds
|
|
161
161
|
signal_name (str): signal name for graceful termination
|
|
162
162
|
no_output (bool): suppress normal output
|
|
163
163
|
|
|
@@ -170,7 +170,10 @@ def run_command(
|
|
|
170
170
|
timer: threading.Timer | None = None
|
|
171
171
|
|
|
172
172
|
try:
|
|
173
|
-
creationflags =
|
|
173
|
+
creationflags = (
|
|
174
|
+
getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if _is_windows() else 0
|
|
175
|
+
)
|
|
176
|
+
|
|
174
177
|
start_new_session = not _is_windows()
|
|
175
178
|
|
|
176
179
|
process = subprocess.Popen(
|
|
@@ -236,7 +239,7 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
236
239
|
|
|
237
240
|
parser.add_argument(
|
|
238
241
|
"--sec",
|
|
239
|
-
type=
|
|
242
|
+
type=float,
|
|
240
243
|
default=_Const.DEFAULT_TIMEOUT_S,
|
|
241
244
|
help=f"timeout in seconds (default: {_Const.DEFAULT_TIMEOUT_S})",
|
|
242
245
|
metavar="SECONDS",
|
|
@@ -271,7 +274,7 @@ def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
|
271
274
|
# ------------------------------------------------
|
|
272
275
|
|
|
273
276
|
|
|
274
|
-
def print_header(command: str, timeout:
|
|
277
|
+
def print_header(command: str, timeout: float) -> None:
|
|
275
278
|
"""Print execution header."""
|
|
276
279
|
|
|
277
280
|
print(f"Running: {command}")
|
|
@@ -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
|
|
|
@@ -460,8 +530,8 @@ class TestIntegration:
|
|
|
460
530
|
|
|
461
531
|
class TestConstants:
|
|
462
532
|
def test_default_timeout(self) -> None:
|
|
463
|
-
assert _Const.DEFAULT_TIMEOUT_S == 60
|
|
464
|
-
assert isinstance(_Const.DEFAULT_TIMEOUT_S,
|
|
533
|
+
assert _Const.DEFAULT_TIMEOUT_S == 60.0
|
|
534
|
+
assert isinstance(_Const.DEFAULT_TIMEOUT_S, float)
|
|
465
535
|
|
|
466
536
|
# ------------------------------------------------
|
|
467
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|