Fix shutdown() task-wait to respect SHUTDOWN_TIMEOUT deadline (PCC-989) - #100
Conversation
The shutdown() method in waiting_server.py has two wait loops. The first loop, which waits for open connections to close, checks should_exit_deadline and gives up once the deadline passes. The second loop, which waits for background tasks to finish, did not check the deadline at all. It only ended when the task set emptied or force_exit became true. force_exit only flips on a second SIGTERM/SIGINT, which Kubernetes never sends. It sends one SIGTERM, waits the pod grace period, then SIGKILLs. A live bot session keeps its FastAPI BackgroundTask in server_state.tasks for the whole session, so the task set never empties while a session is running. The result: if SIGTERM arrives during an active session, shutdown() blocks in the second loop with no timeout, no matter what SHUTDOWN_TIMEOUT is set to. From the outside this looks like SIGTERM being swallowed until Kubernetes kills the pod. This defeats the timeout that SHUTDOWN_TIMEOUT is supposed to provide on the main bot-session path. The fix adds the same three-part deadline check the connections loop already uses. Also adds a regression test that the task-wait loop gives up at the deadline instead of hanging.
There was a problem hiding this comment.
Pull request overview
This PR fixes WaitingServer.shutdown() so the background-task wait loop honors the same SHUTDOWN_TIMEOUT/should_exit_deadline deadline as the open-connections wait loop, preventing shutdown from hanging indefinitely during in-flight bot sessions (PCC-989).
Changes:
- Apply the shutdown deadline condition to the background-tasks wait loop in
WaitingServer.shutdown(). - Add a regression test ensuring shutdown returns after the deadline even when
server_state.tasksnever empties. - Add a control test ensuring shutdown returns quickly when there are no tasks.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
pipecat-base/waiting_server.py |
Adds the missing deadline condition to the background-task wait loop in shutdown() so SHUTDOWN_TIMEOUT is respected. |
pipecat-base/tests/test_waiting_server.py |
Adds regression coverage for the deadline behavior (and a no-tasks control case). |
Comments suppressed due to low confidence (1)
pipecat-base/tests/test_waiting_server.py:54
- Use time.monotonic() for interval measurement to avoid flakiness from wall-clock adjustments.
start = time.time()
asyncio.run(asyncio.wait_for(server.shutdown(), timeout=5.0))
elapsed = time.time() - start
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…clock Address code review on the PCC-989 shutdown fix: - Coerce SHUTDOWN_TIMEOUT to float in app.py and Config. The env value arrives as a string, so the deadline math raised TypeError and crashed shutdown before the wait loops ran (the exact PCC-989 symptom). - Bound the final lifespan.shutdown() by the same deadline so a stuck app shutdown handler cannot push total shutdown past SHUTDOWN_TIMEOUT. - Use time.monotonic() for deadline math so NTP steps do not skew it. - Simplify the redundant post-loop warning guard. - Add a regression test for the string-timeout path; fix the no-tasks test to measure elapsed time with a monotonic clock.
mattshep
left a comment
There was a problem hiding this comment.
Verified the diagnosis independently — it's correct, and the fix is right. Approving. Details of what I checked:
Diagnosis verification
- Tasks loop lacks the deadline on
main— confirmed: the connections loop checksshould_exit_deadline, the tasks loop only checksself.server_state.tasks and not self.force_exit. - A live session really does pin
server_state.tasks— the HTTP start paths (app.pylines 355/476) launchrun_botvia StarletteBackgroundTasks, which runs inside the same ASGI call, so uvicorn's per-request task stays inserver_state.tasksfor the whole session. (Theawait run_bot(...)paths at lines 229/258 hold a connection instead, and that loop already honored the deadline.) force_exitcan never rescue the loop in k8s — current uvicorn'shandle_exitonly setsforce_exiton a second SIGINT (if self.should_exit and sig == signal.SIGINT). A second SIGTERM wouldn't even flip it, and k8s never sends one anyway. The claim in the PR body is if anything understated.
Test verification
Ran the suite on the PR head in a clean worktree: 22 passed. Then swapped waiting_server.py back to main's version and re-ran the new tests:
test_tasks_wait_gives_up_at_deadline— fails viaasyncio.wait_forTimeoutError (the hang, exactly the reported symptom)test_string_timeout_does_not_crash_shutdown— fails with TypeError at the deadline computation
So both regression tests genuinely bite against the old code.
One thing worth calling out more loudly in the PR body
The float() coercion is really a second, distinct bug fix, not a hygiene tweak. Pre-fix behavior forked on whether the env var was set:
SHUTDOWN_TIMEOUTunset → default is the int7200→ deadline math works → the tasks-loop hang described in the body.SHUTDOWN_TIMEOUTset (env vars are always strings) →time.time() + "300"raisesTypeErrorinsideshutdown()→ shutdown aborts with a traceback instead of hanging. Different failure signature, same net effect ofSHUTDOWN_TIMEOUTnever being honored.
Worth a sentence in the description so anyone matching this against prod symptoms knows there were two presentations. Side note: with the fix, a garbage value (e.g. SHUTDOWN_TIMEOUT=abc) now fails fast at import/boot via float() in app.py rather than at shutdown — I think that's the right trade.
Behavior-change note (fine, but should be conscious)
With PCC defaults (SHUTDOWN_TIMEOUT=7200, pod grace 7500), a bot that gets SIGTERM mid-session now exits cleanly at 7200s instead of riding to SIGKILL at 7500s. Sessions are bounded by maxSessionDuration=7200 anyway, so nothing is newly cut short — but customers who set SHUTDOWN_TIMEOUT low will now have in-flight sessions actually terminated at that bound, which is the documented contract finally being enforced.
The bounded lifespan-shutdown (asyncio.wait_for + skip-if-expired) looks right too; cancelling lifespan.shutdown() at that point is safe since the process is exiting regardless.
Relation to PCC-989
Agree with the framing that this is independent of the operator-side grace-cap fix (pipecat-cloud-operator PR 232): for dscout specifically, PID 1 never forwards SIGTERM, so this code path never even runs there. This fixes the adjacent population — any pipecat-base bot with an active session — which presents with the same "SIGTERM apparently swallowed until SIGKILL" fingerprint.
|
@jamsea Before merging, please update the version in pyproject.toml and add a CHANGELOG.md entry. |
The Dockerfile's uv sync --locked validates the lockfile against pyproject.toml, so the version bump re-locks too (one-line change).
…tasks-deadline # Conflicts: # pipecat-base/CHANGELOG.md # pipecat-base/pyproject.toml # pipecat-base/uv.lock
The bug
WaitingServer.shutdown()inpipecat-base/waiting_server.pyhas two wait loops:The first loop checks
should_exit_deadline(built fromSHUTDOWN_TIMEOUT) and gives up once the deadline passes. The second loop did not check the deadline at all. It only ended whenserver_state.tasksemptied orforce_exitbecame true.force_exitonly flips on a second SIGTERM/SIGINT, which Kubernetes never sends. Kubernetes sends one SIGTERM, waits the pod grace period, then SIGKILLs. There is no second signal.A live bot session is started with
background_tasks.add_task(run_bot, ...)(seeapp.py), a FastAPI BackgroundTask that uvicorn tracks inserver_state.tasksuntil the request finishes.run_botruns for the whole session, so the task set never empties while a session is active.Net effect: if SIGTERM arrives during an active session,
shutdown()blocks in the second loop with no timeout, no matter whatSHUTDOWN_TIMEOUTis set to. From the outside this looks exactly like SIGTERM being swallowed until Kubernetes kills the pod. It silently defeats the timeout thatSHUTDOWN_TIMEOUTis supposed to give on the main bot-session path.Reproduced locally: an idle container exits in ~1s on SIGTERM, but a container with an in-flight background task stays alive and unresponsive well past a 5s
SHUTDOWN_TIMEOUT, log frozen at "Waiting for background tasks to complete."Why it matters (PCC-989)
PCC-989 tracks bot containers getting SIGKILLed mid-session with no error logs, well after SIGTERM was sent. There is a separate operator-side fix in progress (pipecat-cloud-operator #232) that caps the grace period for a boot-time liveness restart. This change is independent of that one: it fixes the runtime side so
SHUTDOWN_TIMEOUTis actually honored on the primary session code path, instead of the shutdown hanging until the pod grace period runs out.The fix
One-line change: add the same three-part deadline condition the connections loop already uses to the tasks loop.
Test
Added
pipecat-base/tests/test_waiting_server.pywith a regression test that the task-wait loop gives up at the deadline instead of hanging, plus a control case for no active tasks. Confirmed the deadline test fails (times out) against the old code and passes with the fix. Full suite: 21 passed. Ruff check and format clean.Draft: opening for review, not ready to merge.