Bug Description
Worker SIGTERM handler raises _ExitCli inside job-request tasks, preventing graceful drain
Component: livekit-agents worker CLI (livekit/agents/cli/cli.py)
Impact: SIGTERM/SIGINT graceful shutdown is unreliable for workers whose request_fnc does synchronous/blocking I/O (HTTP, database, file locks, etc.). The process ignores orchestrator shutdown, continues to register as available, and may accept new job dispatches until SIGKILL.
We observed this issue in real production ECS self hosted deployment on an old version (1.6.0) but I have provided a minimal example reproducing the issue on latest version. The rest of this ticket I admit is LLM generated text, so please take the suggested solution with a pinch of salt, but the "proof is in the pudding" as they say - the issue is reproducibile.
Summary: _run_worker installs a signal.signal handler that raises _ExitCli (a BaseException subclass) directly from the handler. CPython delivers signal handlers on the main thread at an arbitrary bytecode boundary, so that exception can surface inside whatever coroutine the main thread is currently executing instead of at the loop.run_until_complete(main_task) boundary where it is caught.
When the main thread is inside AgentServer._answer_availability → _job_request_task (very likely whenever a user request_fnc / on_request performs blocking work), _ExitCli propagates out of that asyncio task. asyncio logs Task exception was never retrieved, the worker never enters the graceful drain/server.aclose() path, and shutdown is lost.
Because exit_raised is set to True before the raise, the handler latches: a second signal force-exits via os._exit(1) rather than retrying shutdown. In container environments that send a single SIGTERM and later SIGKILL, the first signal is wasted and the worker keeps accepting dispatches until killed.
worker.py wraps _job_request_task in except Exception (added in 1.6.x), but _ExitCli subclasses BaseException, not Exception, so it is not caught. Behavior is the same on 1.6.0 (original observation), 1.6.6 (re-verified), and 1.6.8 (reproduced against LiveKit Cloud in start mode).
The TCP/console path in the same module already uses the signal-safe pattern: asyncio.run_coroutine_threadsafe(_graceful_shutdown(), loop) instead of raising from the handler.
Observed symptom (framework frames only; reproduced on 1.6.8):
Task exception was never retrieved
future: <Task finished name='job_request' coro=<AgentServer._answer_availability.<locals>._job_request_task() done, defined at livekit/agents/utils/log.py:14> exception=_ExitCli()>
Traceback (most recent call last):
File "livekit/agents/utils/log.py", line 17, in async_fn_logs
return await fn(*args, **kwargs)
File "livekit/agents/worker.py", line 1438, in _job_request_task
await self._request_fnc(job_req)
File "<user request_fnc>", line <N>, in request_fnc
time.sleep(60)
File "livekit/agents/cli/cli.py", line 304, in _handle_exit
raise _ExitCli()
livekit.agents.cli.cli._ExitCli
After the first SIGTERM, the worker stayed alive and accepted a new job dispatch ~2s later (same room), confirming it never drained and remained available.
Impact: SIGTERM/SIGINT graceful shutdown is unreliable for workers whose request_fnc / on_request does synchronous/blocking I/O (HTTP, database, file locks, etc.). The process ignores orchestrator shutdown, continues to register as available, and may accept new job dispatches until SIGKILL.
Expected Behavior
Sending SIGTERM or SIGINT to a running worker should reliably trigger graceful shutdown (drain → server.aclose()), regardless of whether request_fnc is currently executing blocking work on the main thread.
Reproduction Steps
Use src/minimal_worker.py in this repo (or the snippet below). Requires LiveKit credentials in the environment (e.g. .env.local).
# minimal_worker.py — run with livekit-agents >= 1.6.0
import asyncio
import os
import time
from dotenv import load_dotenv
from livekit.agents import AgentServer, JobContext, JobRequest, cli
server = AgentServer()
load_dotenv(".env.local")
async def request_fnc(job: JobRequest) -> None:
# The blocking sleep is the point of this repro: it keeps the main thread inside
# AgentServer._answer_availability → _job_request_task, which is where the _ExitCli
# raised by the SIGTERM handler in cli._run_worker lands instead of at the
# run_until_complete boundary.
print(f"REPRO: request_fnc entered, blocking for 60s (pid={os.getpid()})", flush=True)
time.sleep(60) # noqa: ASYNC251
print("REPRO: request_fnc finished blocking", flush=True)
await job.accept()
@server.rtc_session(on_request=request_fnc)
async def entrypoint(ctx: JobContext) -> None:
await asyncio.sleep(0)
if __name__ == "__main__":
cli.run_app(server)
Note: On 1.6.x, AgentServer no longer accepts WorkerOptions positionally.
AgentServer(WorkerOptions(...)) raises
TypeError: AgentServer.__init__() takes 1 positional argument but 2 were given.
Register the entrypoint and on_request via @server.rtc_session(...) as above
(or pass a legacy WorkerOptions directly to cli.run_app).
1. Start the worker in production mode:
uv run python -m src.minimal_worker start
2. Dispatch any job so request_fnc runs (automatic dispatch works when agent_name
is empty; e.g. `lk room join --identity repro-tester <room>`).
3. While request_fnc is inside time.sleep(), send SIGTERM: kill -TERM <worker-pid>
4. Observe: asyncio logs "Task exception was never retrieved" with _ExitCli from
_job_request_task; the worker process stays alive, keeps heartbeating, and
continues accepting dispatches until a second SIGTERM (os._exit latch) or SIGKILL.
5. Expected (but does not happen): worker enters drain and shuts down gracefully.
Optionally this bash script reproduces
#!/usr/bin/env bash
set -Eeuo pipefail
# Reproduction driver for the _ExitCli signal race:
# dispatch a job so request_fnc blocks the main thread, then SIGTERM the worker.
cd "$(dirname "$0")/.."
set -a
# shellcheck disable=SC1091
source .env.local
set +a
WORKER_PID="$1"
ROOM="repro-$(date +%s)"
echo "== joining room ${ROOM} to trigger automatic dispatch =="
lk room join --identity repro-tester "${ROOM}" > tmp/lk-join.log 2>&1 &
LK_PID=$!
for _ in $(seq 1 60); do
if grep -q "REPRO: request_fnc entered" tmp/worker.log; then
break
fi
sleep 1
done
if ! grep -q "REPRO: request_fnc entered" tmp/worker.log; then
echo "!! request_fnc never ran; dispatch did not reach the worker"
kill "${LK_PID}" 2>/dev/null || true
exit 1
fi
echo "== request_fnc is blocking; sending SIGTERM to ${WORKER_PID} =="
date +%T
kill -TERM "${WORKER_PID}"
for i in $(seq 1 20); do
sleep 1
if ! kill -0 "${WORKER_PID}" 2>/dev/null; then
echo "== worker exited ${i}s after SIGTERM =="
kill "${LK_PID}" 2>/dev/null || true
exit 0
fi
done
echo "== worker STILL ALIVE 20s after SIGTERM =="
kill "${LK_PID}" 2>/dev/null || true
exit 0
Reproduction Steps
1.
2.
3.
...
- Sample code snippet, or a GitHub Gist link -
Operating System
Linux (containerized worker, Python 3.13)
Models Used
N/A (no STT/LLM/TTS providers involved; worker dispatch lifecycle only)
Package Versions
livekit==1.1.13
livekit-agents==1.6.0 # originally observed
livekit-agents==1.6.6 # re-verified still present
Session/Room/Call IDs
N/A — reproduced with a synthetic request_fnc and no real sessions.
Proposed Solution
Align `_run_worker`'s signal handling with the TCP/console path in the same file (`_run_tcp_console`):
# livekit/agents/cli/cli.py — _run_worker (current, problematic)
def _handle_exit(sig: int, frame: FrameType | None) -> None:
nonlocal exit_raised
if exit_raised:
os._exit(1)
exit_raised = True
raise _ExitCli() # can land inside an arbitrary asyncio task
# Proposed: schedule shutdown on the event loop (signal-safe)
async def _graceful_shutdown() -> None:
if not devmode:
try:
await server.drain()
except asyncio.TimeoutError:
logger.warning("drain timed out, forcing shutdown")
await server.aclose()
if watch_client:
await watch_client.aclose()
loop.stop() # unblock run_until_complete
def _handle_exit(sig: int, frame: FrameType | None) -> None:
nonlocal exit_raised
if exit_raised:
os._exit(1)
exit_raised = True
asyncio.run_coroutine_threadsafe(_graceful_shutdown(), loop)
Alternatively, on POSIX, register handlers with `loop.add_signal_handler(sig, ...)` so delivery stays on the event-loop thread.
Either approach avoids raising into asyncio task frames and makes SIGTERM shutdown reliable when `request_fnc` blocks.
Additional Context
- Raising exceptions from
signal.signal handlers is a known CPython footgun: the handler runs on the main thread at the next bytecode boundary, which may be deep inside an unrelated call stack.
- Workers behind container orchestrators typically receive exactly one SIGTERM before SIGKILL; a latched one-shot handler that can miss its target is especially dangerous.
- Users cannot fully mitigate this in
request_fnc alone — any blocking call on the event loop reopens the race window.
Screenshots and Recordings
No response
Bug Description
Worker SIGTERM handler raises
_ExitCliinside job-request tasks, preventing graceful drainComponent:
livekit-agentsworker CLI (livekit/agents/cli/cli.py)Impact: SIGTERM/SIGINT graceful shutdown is unreliable for workers whose
request_fncdoes synchronous/blocking I/O (HTTP, database, file locks, etc.). The process ignores orchestrator shutdown, continues to register as available, and may accept new job dispatches until SIGKILL.We observed this issue in real production ECS self hosted deployment on an old version (1.6.0) but I have provided a minimal example reproducing the issue on latest version. The rest of this ticket I admit is LLM generated text, so please take the suggested solution with a pinch of salt, but the "proof is in the pudding" as they say - the issue is reproducibile.
Summary:
_run_workerinstalls asignal.signalhandler that raises_ExitCli(aBaseExceptionsubclass) directly from the handler. CPython delivers signal handlers on the main thread at an arbitrary bytecode boundary, so that exception can surface inside whatever coroutine the main thread is currently executing instead of at theloop.run_until_complete(main_task)boundary where it is caught.When the main thread is inside
AgentServer._answer_availability→_job_request_task(very likely whenever a userrequest_fnc/on_requestperforms blocking work),_ExitClipropagates out of that asyncio task. asyncio logsTask exception was never retrieved, the worker never enters the graceful drain/server.aclose()path, and shutdown is lost.Because
exit_raisedis set toTruebefore the raise, the handler latches: a second signal force-exits viaos._exit(1)rather than retrying shutdown. In container environments that send a single SIGTERM and later SIGKILL, the first signal is wasted and the worker keeps accepting dispatches until killed.worker.pywraps_job_request_taskinexcept Exception(added in 1.6.x), but_ExitClisubclassesBaseException, notException, so it is not caught. Behavior is the same on 1.6.0 (original observation), 1.6.6 (re-verified), and 1.6.8 (reproduced against LiveKit Cloud instartmode).The TCP/console path in the same module already uses the signal-safe pattern:
asyncio.run_coroutine_threadsafe(_graceful_shutdown(), loop)instead of raising from the handler.Observed symptom (framework frames only; reproduced on 1.6.8):
After the first SIGTERM, the worker stayed alive and accepted a new job dispatch ~2s later (same room), confirming it never drained and remained available.
Impact: SIGTERM/SIGINT graceful shutdown is unreliable for workers whose
request_fnc/on_requestdoes synchronous/blocking I/O (HTTP, database, file locks, etc.). The process ignores orchestrator shutdown, continues to register as available, and may accept new job dispatches until SIGKILL.Expected Behavior
Sending SIGTERM or SIGINT to a running worker should reliably trigger graceful shutdown (drain →
server.aclose()), regardless of whetherrequest_fncis currently executing blocking work on the main thread.Reproduction Steps
Use
src/minimal_worker.pyin this repo (or the snippet below). Requires LiveKit credentials in the environment (e.g..env.local).Optionally this bash script reproduces
Reproduction Steps
Operating System
Linux (containerized worker, Python 3.13)
Models Used
N/A (no STT/LLM/TTS providers involved; worker dispatch lifecycle only)
Package Versions
Session/Room/Call IDs
N/A — reproduced with a synthetic
request_fncand no real sessions.Proposed Solution
Additional Context
signal.signalhandlers is a known CPython footgun: the handler runs on the main thread at the next bytecode boundary, which may be deep inside an unrelated call stack.request_fncalone — any blocking call on the event loop reopens the race window.Screenshots and Recordings
No response