Skip to content

Commit 1da4210

Browse files
manan164claude
andcommitted
Add poll-loop liveness watchdog to auto-restart wedged workers
Workers occasionally stop polling and stay stopped until manually restarted. The poll loop runs poll + execute + update on one thread (a single event loop for async workers); if a poll/update call never returns (e.g. a stale keep-alive connection silently dropped by a proxy/LB/NAT, which never reaches the server so server metrics stay clean) or a blocking call freezes the loop, polling halts with no error. TaskHandler already supervises and restarts worker processes, but only when is_alive()==False (task_handler.py). A wedged-but-alive process is invisible to it, so the worker stays dead until an operator restarts it. This adds a liveness watchdog to both TaskRunner and AsyncTaskRunner: run_once() records a monotonic heartbeat each iteration (a healthy loop reaches it within ms even at full capacity), and a daemon thread (so a frozen loop can't block it) exits the process via os._exit when the loop has been silent past CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS (default 300s, 0 to disable). The existing supervisor then restarts it, turning a permanent stall into a few-second blip. This is a defense-in-depth backstop for the symptom, not a root-cause fix: it auto-recovers a stalled worker regardless of why it wedged, and its critical log points operators at capturing a stack dump (py-spy dump --pid) to diagnose the underlying blocked call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d9f06f9 commit 1da4210

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

src/conductor/client/automator/async_task_runner.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import os
55
import sys
6+
import threading
67
import time
78
import traceback
89

@@ -40,6 +41,28 @@
4041
)
4142
)
4243

44+
# Exit code used when the poll-loop liveness watchdog restarts a wedged worker.
45+
# Distinct, non-zero value so operators can recognise watchdog restarts and so
46+
# TaskHandler's supervisor (restart_on_failure) treats it as a failure exit.
47+
POLL_STALL_EXIT_CODE = 70 # EX_SOFTWARE
48+
49+
50+
def _get_poll_stall_timeout_seconds() -> int:
51+
"""Max seconds the poll loop may be silent before the watchdog restarts the
52+
worker process. ``0`` disables the watchdog.
53+
54+
Override via env ``CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS``
55+
(default 300s). The default is intentionally generous: a healthy poll loop
56+
iterates within milliseconds even when every slot is busy, so a multi-minute
57+
silence means the event loop is wedged (a never-returning poll/update, or a
58+
blocking call that froze the loop) rather than legitimately busy.
59+
"""
60+
raw = os.getenv("CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS", "300")
61+
try:
62+
return max(0, int(float(raw)))
63+
except (TypeError, ValueError):
64+
return 300
65+
4366

4467
class AsyncTaskRunner:
4568
"""
@@ -117,6 +140,18 @@ def __init__(
117140
self._tracked_task_ids = set() # Local set for cleanup on shutdown
118141
self._sync_task_client = None # Created after fork for LeaseManager heartbeats
119142

143+
# Poll-loop liveness watchdog. The event loop runs poll + execute +
144+
# update on a single thread; if a poll/update await never returns (e.g.
145+
# a stale keep-alive connection silently dropped by a proxy/LB) or a
146+
# blocking call freezes the loop, polling stops silently. TaskHandler's
147+
# supervisor only restarts DEAD processes (is_alive()==False), so a
148+
# wedged-but-alive worker stays stuck until a manual restart. The
149+
# watchdog (a daemon thread, so a frozen loop can't block it) detects a
150+
# stalled poll loop and exits the process so the supervisor restarts it.
151+
self._poll_stall_timeout = _get_poll_stall_timeout_seconds()
152+
self._last_loop_activity = time.monotonic()
153+
self._watchdog_thread = None
154+
120155
async def run(self) -> None:
121156
"""Main async loop - runs continuously in single event loop."""
122157
if self.configuration is not None:
@@ -149,6 +184,10 @@ async def run(self) -> None:
149184
# Create semaphore in the event loop (must be created within the loop)
150185
self._semaphore = asyncio.Semaphore(self._max_workers)
151186

187+
# Start the poll-loop liveness watchdog (runs in this worker subprocess)
188+
self._last_loop_activity = time.monotonic()
189+
self.__start_poll_watchdog()
190+
152191
# Log worker configuration with correct PID (after fork)
153192
task_name = self.worker.get_task_definition_name()
154193
config_summary = get_worker_config_oneline(task_name, self._resolved_config)
@@ -177,6 +216,62 @@ async def stop(self) -> None:
177216
"""Signal the runner to stop gracefully."""
178217
self._shutdown = True
179218

219+
# -- Poll-loop liveness watchdog -------------------------------------------
220+
221+
def __start_poll_watchdog(self) -> None:
222+
"""Start the daemon watchdog thread (no-op if disabled)."""
223+
if self._poll_stall_timeout <= 0:
224+
logger.debug("Poll-loop watchdog disabled (stall timeout <= 0)")
225+
return
226+
if self._watchdog_thread is not None and self._watchdog_thread.is_alive():
227+
return
228+
self._watchdog_thread = threading.Thread(
229+
target=self.__poll_watchdog_loop,
230+
name=f"poll-watchdog-{self.worker.get_task_definition_name()}",
231+
daemon=True,
232+
)
233+
self._watchdog_thread.start()
234+
logger.info(
235+
"Poll-loop watchdog started for '%s' (stall_timeout=%ss)",
236+
self.worker.get_task_definition_name(),
237+
self._poll_stall_timeout,
238+
)
239+
240+
def __poll_watchdog_loop(self) -> None:
241+
# Check a few times per stall window, but at least every second and at
242+
# most every 30s, so detection is timely without busy-spinning.
243+
check_interval = max(1.0, min(self._poll_stall_timeout / 5.0, 30.0))
244+
while not self._shutdown:
245+
time.sleep(check_interval)
246+
self._check_poll_stall()
247+
248+
def _check_poll_stall(self) -> bool:
249+
"""Restart the process if the poll loop has been silent too long.
250+
251+
Returns True if a stall was detected (after which the process exits).
252+
Extracted from the watchdog loop so it can be unit-tested without the
253+
sleep loop. Calls ``os._exit`` so a wedged event loop can't intercept it.
254+
"""
255+
if self._poll_stall_timeout <= 0 or self._shutdown:
256+
return False
257+
idle = time.monotonic() - self._last_loop_activity
258+
if idle < self._poll_stall_timeout:
259+
return False
260+
logger.critical(
261+
"Poll loop for '%s' stalled %.0fs (>= %ss): the event loop is wedged "
262+
"and not polling. Exiting (code %d) so the supervisor restarts this "
263+
"worker. If this recurs, capture a stack dump (py-spy dump --pid <pid>) "
264+
"to find the blocked call.",
265+
self.worker.get_task_definition_name(),
266+
idle,
267+
self._poll_stall_timeout,
268+
POLL_STALL_EXIT_CODE,
269+
)
270+
os._exit(POLL_STALL_EXIT_CODE)
271+
return True # pragma: no cover - process has exited
272+
273+
# --------------------------------------------------------------------------
274+
180275
async def _cleanup(self) -> None:
181276
"""Clean up async resources."""
182277
logger.debug("Cleaning up AsyncTaskRunner resources...")
@@ -462,6 +557,10 @@ async def __async_register_task_definition(self) -> None:
462557

463558
async def run_once(self) -> None:
464559
"""Execute one iteration of the polling loop (async version)."""
560+
# Liveness heartbeat for the watchdog: a healthy loop reaches this every
561+
# iteration (within ms, even at full capacity). A stale value means the
562+
# previous iteration is wedged on an await that never returned.
563+
self._last_loop_activity = time.monotonic()
465564
try:
466565
# No need for manual cleanup - tasks remove themselves via add_done_callback
467566
# Just check capacity directly

src/conductor/client/automator/task_runner.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,28 @@
4444
)
4545
)
4646

47+
# Exit code used when the poll-loop liveness watchdog restarts a wedged worker.
48+
# Distinct, non-zero value so operators can recognise watchdog restarts and so
49+
# TaskHandler's supervisor (restart_on_failure) treats it as a failure exit.
50+
POLL_STALL_EXIT_CODE = 70 # EX_SOFTWARE
51+
52+
53+
def _get_poll_stall_timeout_seconds() -> int:
54+
"""Max seconds the poll loop may be silent before the watchdog restarts the
55+
worker process. ``0`` disables the watchdog.
56+
57+
Override via env ``CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS``
58+
(default 300s). The default is intentionally generous: a healthy poll loop
59+
iterates within milliseconds even when every slot is busy, so a multi-minute
60+
silence means the loop is wedged on a call that never returned rather than
61+
legitimately busy.
62+
"""
63+
raw = os.getenv("CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS", "300")
64+
try:
65+
return max(0, int(float(raw)))
66+
except (TypeError, ValueError):
67+
return 300
68+
4769

4870
class TaskRunner:
4971
def __init__(
@@ -116,6 +138,17 @@ def __init__(
116138
self._tracked_task_ids = set() # Local set for cleanup on shutdown
117139
self._tracked_task_ids_lock = threading.Lock()
118140

141+
# Poll-loop liveness watchdog. If the poll thread wedges on a call that
142+
# never returns (e.g. a stale keep-alive connection silently dropped by
143+
# a proxy/LB), polling stops silently. TaskHandler's supervisor only
144+
# restarts DEAD processes (is_alive()==False), so a wedged-but-alive
145+
# worker stays stuck until a manual restart. The watchdog (a daemon
146+
# thread) detects a stalled poll loop and exits the process so the
147+
# supervisor restarts it.
148+
self._poll_stall_timeout = _get_poll_stall_timeout_seconds()
149+
self._last_loop_activity = time.monotonic()
150+
self._watchdog_thread = None
151+
119152
def run(self) -> None:
120153
if self.configuration is not None:
121154
self.configuration.apply_logging_config()
@@ -139,6 +172,10 @@ def run(self) -> None:
139172
self.worker.get_polling_interval_in_seconds()
140173
)
141174

175+
# Start the poll-loop liveness watchdog (runs in this worker subprocess)
176+
self._last_loop_activity = time.monotonic()
177+
self.__start_poll_watchdog()
178+
142179
try:
143180
while not self._shutdown:
144181
self.run_once()
@@ -150,6 +187,60 @@ def stop(self) -> None:
150187
"""Signal the runner to stop gracefully."""
151188
self._shutdown = True
152189

190+
# -- Poll-loop liveness watchdog -------------------------------------------
191+
192+
def __start_poll_watchdog(self) -> None:
193+
"""Start the daemon watchdog thread (no-op if disabled)."""
194+
if self._poll_stall_timeout <= 0:
195+
logger.debug("Poll-loop watchdog disabled (stall timeout <= 0)")
196+
return
197+
if self._watchdog_thread is not None and self._watchdog_thread.is_alive():
198+
return
199+
self._watchdog_thread = threading.Thread(
200+
target=self.__poll_watchdog_loop,
201+
name=f"poll-watchdog-{self.worker.get_task_definition_name()}",
202+
daemon=True,
203+
)
204+
self._watchdog_thread.start()
205+
logger.info(
206+
"Poll-loop watchdog started for '%s' (stall_timeout=%ss)",
207+
self.worker.get_task_definition_name(),
208+
self._poll_stall_timeout,
209+
)
210+
211+
def __poll_watchdog_loop(self) -> None:
212+
check_interval = max(1.0, min(self._poll_stall_timeout / 5.0, 30.0))
213+
while not self._shutdown:
214+
time.sleep(check_interval)
215+
self._check_poll_stall()
216+
217+
def _check_poll_stall(self) -> bool:
218+
"""Restart the process if the poll loop has been silent too long.
219+
220+
Returns True if a stall was detected (after which the process exits).
221+
Extracted from the watchdog loop so it can be unit-tested without the
222+
sleep loop. Calls ``os._exit`` so a wedged loop can't intercept it.
223+
"""
224+
if self._poll_stall_timeout <= 0 or self._shutdown:
225+
return False
226+
idle = time.monotonic() - self._last_loop_activity
227+
if idle < self._poll_stall_timeout:
228+
return False
229+
logger.critical(
230+
"Poll loop for '%s' stalled %.0fs (>= %ss): the poll loop is wedged "
231+
"and not polling. Exiting (code %d) so the supervisor restarts this "
232+
"worker. If this recurs, capture a stack dump (py-spy dump --pid <pid>) "
233+
"to find the blocked call.",
234+
self.worker.get_task_definition_name(),
235+
idle,
236+
self._poll_stall_timeout,
237+
POLL_STALL_EXIT_CODE,
238+
)
239+
os._exit(POLL_STALL_EXIT_CODE)
240+
return True # pragma: no cover - process has exited
241+
242+
# --------------------------------------------------------------------------
243+
153244
def _cleanup(self) -> None:
154245
"""Clean up resources - called on exit."""
155246
logger.debug("Cleaning up TaskRunner resources...")
@@ -432,6 +523,10 @@ def __register_task_definition(self) -> None:
432523
logger.warning(f"Failed to register task definition for {task_name}: {e}")
433524

434525
def run_once(self) -> None:
526+
# Liveness heartbeat for the watchdog: a healthy loop reaches this every
527+
# iteration (within ms, even at full capacity). A stale value means the
528+
# previous iteration is wedged on a call that never returned.
529+
self._last_loop_activity = time.monotonic()
435530
try:
436531
# Check completed async tasks first (non-blocking)
437532
self.__check_completed_async_tasks()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Unit tests for the poll-loop liveness watchdog on TaskRunner / AsyncTaskRunner.
2+
3+
The watchdog restarts a worker whose poll loop has gone silent (a wedged event
4+
loop / poll thread that TaskHandler's is_alive()-only supervisor cannot detect).
5+
We test the decision logic and env parsing; os._exit is patched so the test
6+
process survives.
7+
"""
8+
import asyncio
9+
import os
10+
import time
11+
import unittest
12+
from unittest.mock import patch
13+
14+
from conductor.client.automator import async_task_runner as atr
15+
from conductor.client.automator import task_runner as tr
16+
from conductor.client.automator.async_task_runner import AsyncTaskRunner
17+
from conductor.client.automator.task_runner import TaskRunner
18+
from conductor.client.configuration.configuration import Configuration
19+
from conductor.client.worker.worker import Worker
20+
21+
22+
def _async_worker():
23+
async def fn() -> dict:
24+
await asyncio.sleep(0)
25+
return {}
26+
return Worker(task_definition_name="wd_async", execute_function=fn, thread_count=2)
27+
28+
29+
def _sync_worker():
30+
def fn() -> dict:
31+
return {}
32+
return Worker(task_definition_name="wd_sync", execute_function=fn, thread_count=2)
33+
34+
35+
class TestPollStallTimeoutEnv(unittest.TestCase):
36+
def setUp(self):
37+
self.original_env = os.environ.copy()
38+
39+
def tearDown(self):
40+
os.environ.clear()
41+
os.environ.update(self.original_env)
42+
43+
def test_default_is_300(self):
44+
os.environ.pop("CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS", None)
45+
self.assertEqual(atr._get_poll_stall_timeout_seconds(), 300)
46+
self.assertEqual(tr._get_poll_stall_timeout_seconds(), 300)
47+
48+
def test_custom_value(self):
49+
os.environ["CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS"] = "45"
50+
self.assertEqual(atr._get_poll_stall_timeout_seconds(), 45)
51+
52+
def test_zero_disables(self):
53+
os.environ["CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS"] = "0"
54+
self.assertEqual(atr._get_poll_stall_timeout_seconds(), 0)
55+
56+
def test_invalid_falls_back_to_default(self):
57+
os.environ["CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS"] = "not-a-number"
58+
self.assertEqual(atr._get_poll_stall_timeout_seconds(), 300)
59+
60+
def test_negative_clamped_to_zero(self):
61+
os.environ["CONDUCTOR_WORKER_POLL_STALL_TIMEOUT_SECONDS"] = "-5"
62+
self.assertEqual(atr._get_poll_stall_timeout_seconds(), 0)
63+
64+
65+
class _WatchdogChecks:
66+
"""Shared assertions; subclasses provide a freshly-built runner."""
67+
68+
def _make_runner(self): # pragma: no cover - overridden
69+
raise NotImplementedError
70+
71+
def test_fresh_loop_does_not_exit(self):
72+
r = self._make_runner()
73+
r._poll_stall_timeout = 10
74+
r._last_loop_activity = time.monotonic()
75+
with patch.object(os, "_exit") as mock_exit:
76+
self.assertFalse(r._check_poll_stall())
77+
mock_exit.assert_not_called()
78+
79+
def test_stalled_loop_exits_with_code(self):
80+
r = self._make_runner()
81+
r._poll_stall_timeout = 5
82+
r._last_loop_activity = time.monotonic() - 60 # silent for 60s
83+
with patch.object(os, "_exit") as mock_exit:
84+
r._check_poll_stall()
85+
mock_exit.assert_called_once_with(70)
86+
87+
def test_disabled_never_exits_even_when_stale(self):
88+
r = self._make_runner()
89+
r._poll_stall_timeout = 0 # disabled
90+
r._last_loop_activity = time.monotonic() - 99999
91+
with patch.object(os, "_exit") as mock_exit:
92+
self.assertFalse(r._check_poll_stall())
93+
mock_exit.assert_not_called()
94+
95+
def test_shutdown_suppresses_exit(self):
96+
r = self._make_runner()
97+
r._poll_stall_timeout = 5
98+
r._last_loop_activity = time.monotonic() - 60
99+
r._shutdown = True
100+
with patch.object(os, "_exit") as mock_exit:
101+
self.assertFalse(r._check_poll_stall())
102+
mock_exit.assert_not_called()
103+
104+
105+
class TestAsyncWatchdog(_WatchdogChecks, unittest.TestCase):
106+
def _make_runner(self):
107+
return AsyncTaskRunner(worker=_async_worker(), configuration=Configuration())
108+
109+
110+
class TestSyncWatchdog(_WatchdogChecks, unittest.TestCase):
111+
def _make_runner(self):
112+
return TaskRunner(worker=_sync_worker(), configuration=Configuration())
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)