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.
Files changed (24) hide show
  1. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/PKG-INFO +39 -7
  2. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/README.md +38 -6
  3. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/_version.py +3 -3
  4. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/cli.py +12 -9
  5. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/tests/test_cli.py +74 -4
  6. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/publish.yml +0 -0
  7. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/pyright.yml +0 -0
  8. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/ruff.yml +0 -0
  9. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.github/workflows/tests.yml +0 -0
  10. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.gitignore +0 -0
  11. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/.vscode/settings.json +0 -0
  12. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/AGENTS.md +0 -0
  13. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/CODE-STYLE.md +0 -0
  14. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/CONTRIBUTING.md +0 -0
  15. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/LICENSE +0 -0
  16. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/MANIFEST.in +0 -0
  17. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/icon.png +0 -0
  18. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/pyproject.toml +0 -0
  19. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/ruff.toml +0 -0
  20. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/setup.cfg +0 -0
  21. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead/__init__.py +0 -0
  22. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/src/timeout_dead.egg-info/SOURCES.txt +0 -0
  23. {timeout_dead-0.1.2 → timeout_dead-0.2.0}/tests/conftest.py +0 -0
  24. {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.1.2
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 | Windows |
108
- |--------|------|---------|
110
+ | Signal | Unix | Windows |
111
+ | ------ | ------------------------------------- | ---------------------------------- |
109
112
  | `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
110
- | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
111
- | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
112
- | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
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 | Windows |
82
- |--------|------|---------|
84
+ | Signal | Unix | Windows |
85
+ | ------ | ------------------------------------- | ---------------------------------- |
83
86
  | `TERM` | `SIGTERM` (15) — terminate gracefully | `CTRL_BREAK_EVENT` — console break |
84
- | `KILL` | `SIGKILL` (9) — force kill | Falls back to `TerminateProcess` |
85
- | `HUP` | `SIGHUP` (1) — hangup | Falls back to `TerminateProcess` |
86
- | `INT` | `SIGINT` (2) — interrupt (Ctrl+C) | `CTRL_C_EVENT` — console interrupt |
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.1.2'
22
- __version_tuple__ = version_tuple = (0, 1, 2)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g92736f7f7'
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: int = 60
22
+ DEFAULT_TIMEOUT_S: float = 60.0
23
23
  GRACE_PERIOD_S: float = 1.0
24
- HEADER_SEPARATOR: str = "-" * 60
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 {} seconds"
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: int,
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: int = _Const.DEFAULT_TIMEOUT_S,
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 (int): timeout in seconds
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 = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if _is_windows() else 0
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=int,
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: int) -> None:
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, int)
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