Skip to content

Commit 0936aec

Browse files
committed
fix: clean up workflow timeout handling
1 parent 1f8634d commit 0936aec

3 files changed

Lines changed: 35 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ include = [
6868
[tool.pytest.ini_options]
6969
asyncio_mode = "auto"
7070
testpaths = ["tests"]
71+
pythonpath = ["src"]
7172
addopts = "--strict-markers --tb=short -q"
7273

7374
[tool.ruff]

src/hawk/workflow.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from __future__ import annotations
44

55
import asyncio
6-
import concurrent.futures
6+
import queue
7+
import threading
78
from dataclasses import dataclass
89
from typing import Any, Callable, TypeVar
910

@@ -101,20 +102,31 @@ def run(self, initial_input: Any = None) -> Any:
101102
def _run_step_with_timeout(self, s: Step, input_val: Any) -> Any:
102103
"""Run a step in a worker thread with a timeout.
103104
104-
Uses a ThreadPoolExecutor so the timeout works on any thread
105+
Uses a daemon worker thread so the timeout works on any thread
105106
(not just the main thread) and on all platforms (no SIGALRM).
106107
107108
Note: the worker thread keeps running after a timeout; only the
108-
caller is unblocked. This is inherent to cooperative cancellation
109-
in Python and matches asyncio.wait_for semantics.
109+
caller is unblocked. The thread is marked daemon so a timed-out step
110+
cannot keep process shutdown waiting after the caller has moved on.
110111
"""
111112
assert s.timeout is not None
112-
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
113-
future = pool.submit(self._run_step_sync, s, input_val)
113+
result_queue: queue.Queue[tuple[bool, Any]] = queue.Queue(maxsize=1)
114+
115+
def runner() -> None:
114116
try:
115-
return future.result(timeout=s.timeout)
116-
except concurrent.futures.TimeoutError:
117-
raise TimeoutError(f"Step '{s.name}' timed out after {s.timeout}s") from None
117+
result_queue.put((True, self._run_step_sync(s, input_val)))
118+
except Exception as exc:
119+
result_queue.put((False, exc))
120+
121+
worker = threading.Thread(target=runner, name=f"hawk-workflow-{s.name}", daemon=True)
122+
worker.start()
123+
try:
124+
ok, payload = result_queue.get(timeout=s.timeout)
125+
except queue.Empty:
126+
raise TimeoutError(f"Step '{s.name}' timed out after {s.timeout}s") from None
127+
if ok:
128+
return payload
129+
raise payload
118130

119131
def _run_step_sync(self, s: Step, input_val: Any) -> Any:
120132
"""Run a single step with optional retry."""

tests/test_workflow.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import time
67

78
import pytest
89

@@ -97,8 +98,6 @@ def slow(x: int) -> int:
9798
wf.run(1)
9899

99100
def test_timeout_includes_step_name(self) -> None:
100-
import time
101-
102101
def slow(x: int) -> int:
103102
time.sleep(30.0)
104103
return x
@@ -107,6 +106,18 @@ def slow(x: int) -> int:
107106
with pytest.raises(TimeoutError, match="my_step"):
108107
wf.run(1)
109108

109+
def test_timeout_returns_promptly(self) -> None:
110+
def slow(x: int) -> int:
111+
time.sleep(30.0)
112+
return x
113+
114+
wf = Workflow("prompt-timeout").step("slow", slow, timeout=0.5).build()
115+
started = time.perf_counter()
116+
with pytest.raises(TimeoutError, match="timed out"):
117+
wf.run(1)
118+
elapsed = time.perf_counter() - started
119+
assert elapsed < 2.0
120+
110121
def test_no_timeout_when_none(self) -> None:
111122
"""Steps without a timeout run to completion."""
112123
wf = Workflow("no-timeout").step("fast", lambda x: x + 1).build()

0 commit comments

Comments
 (0)