Skip to content

fix: bound PipelineWorker._wait_for_pipeline_end for EndFrame/StopFrame - #5211

Open
johnparkdd wants to merge 2 commits into
pipecat-ai:mainfrom
johnparkdd:bounded-endframe-wait
Open

fix: bound PipelineWorker._wait_for_pipeline_end for EndFrame/StopFrame#5211
johnparkdd wants to merge 2 commits into
pipecat-ai:mainfrom
johnparkdd:bounded-endframe-wait

Conversation

@johnparkdd

Copy link
Copy Markdown
Contributor

Problem

PipelineWorker._process_push_queue awaits _wait_for_pipeline_end(frame) after pushing a finalize frame, and _pipeline_end_event is set only when the frame reaches the worker sink (_sink_push_frame). If a processor never forwards the terminal frame downstream — e.g. its transport/websocket is torn down in the same second an end-of-call sequence begins — the EndFrame/StopFrame branch waits forever: only CancelFrame had a bounded wait (cancel_timeout_secs). The push-queue reader wedges inside that await, and every subsequent worker.cancel() enqueues a CancelFrame that is never read (dead letter). The pipeline zombies until an external task cancellation.

We hit this in production on DoorDash's voice pipelines: an end-of-call races RTVI/transport teardown, the EndFrame is lost, and the 30-second graceful-shutdown watchdog's cancel dead-letters — pipelines zombie for 16–20 minutes until an outer hard timeout kills them (dozens/day, spiking to 15–20/hour during an incident window).

Fix

Give the EndFrame/StopFrame branch the same bounded wait CancelFrame already has (cancel_timeout_secs, default 20s), with the same timeout warning. Success-path behavior is unchanged; on timeout the worker proceeds to the normal cleanup tail (clear() + _finished_event.set()) instead of hanging forever. This also un-dead-letters the fallback: once the bounded wait expires, the queue loop exits and cleanup runs.

Repro / verification

Standalone harness driving a real PipelineWorker with a processor that swallows the EndFrame (never forwards it — the production failure class):

unpatched (1.5.0) patched
EndFrame swallowed mid-pipeline wedged >3× cancel_timeout (zombie) finishes at EndFrame + cancel_timeout
later worker.cancel() dead-lettered, never dequeued not needed
external task cancel only way out not needed

Tests

  • test_task_end_frame_swallowed_finishes_after_cancel_timeout — swallowed EndFrame; worker finishes ≈ cancel_timeout with the timeout warning, instead of hanging.
  • test_task_stop_frame_swallowed_finishes_after_cancel_timeout — same for StopFrame.
  • Existing CancelFrame-timeout test unchanged and passing; worker/pipeline subset: 160 passed.

Notes

  • Backward-compat: workers that previously hung forever now proceed to cleanup after cancel_timeout_secs (default 20s) — the intended improvement; the warning log names the frame and suspects a blocked processor.
  • Complementary to fix: lifecycle frames (EndFrame, CancelFrame) bypass frame-processing pause on teardown #5160 (different deadlock class: pause-gated lifecycle frames vs. a lost terminal frame); no overlap in files.
  • Changelog fragment included (renamed to this PR's number).

PipelineWorker._wait_for_pipeline_end bounded only the CancelFrame wait
with cancel_timeout_secs; the EndFrame/StopFrame branch waited on
_pipeline_end_event indefinitely. If a processor never forwarded the
finalize frame (e.g. a transport torn down mid-shutdown), the push-queue
loop wedged forever and every later CancelFrame (end-call watchdog,
idle auto-cancel, hard-timeout force-cancel) dead-lettered unread.

Give the non-CancelFrame branch the same cancel_timeout_secs bound,
mirroring the existing CancelFrame warning on timeout, then fall through
to the existing clear/set tail. Stock semantics preserved:
on_pipeline_finished is still called only at the sink for
EndFrame/StopFrame (not on the timed-out path).

Co-authored-by: oh-my-pi <https://omp.sh>
@johnparkdd

Copy link
Copy Markdown
Contributor Author

Additional verification — full production end-of-call frame sequence, not just the direct queue_frame(EndFrame) path.

Harness drives a real PipelineWorker with the exact sequence our voice service produces on a bot-initiated end-call: a mid-pipeline processor pushes EndWorkerFrame downstream (our stop_pipeline_with_end_task_frame), it bounces at the sink back upstream, the worker source converts it to EndFrame (_source_push_framequeue_frame(EndFrame)), and a dead downstream processor swallows it (transport torn down first — the prod failure class).

step unpatched (1.5.0) this PR
EndWorkerFrame → sink → bounce → source → EndFrame conversion ✅ observed ✅ observed
EndFrame swallowed by dead downstream zombie: worker wedged in _wait_for_pipeline_end (no timeout) self-resolves at EndFrame + cancel_timeout (4.5s measured with 3.0s timeout)
external task cancellation required (the 20-min hard timeout we run in prod) not needed
dangling pipeline tasks after finish none (TaskManager empty)

Assertions: 6/6 PASS on unpatched (sequence reproduces the zombie), 8/8 PASS on this branch (sequence self-resolves, no rescue, clean teardown). Scenario S6_prod_endworker_sequence in the same harness referenced above.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/pipecat/pipeline/worker.py 86.19% <100.00%> (+0.08%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant