Skip to content

fix(agents): handle rejections from .finally() on background tasks - #2519

Open
Rehansanjay wants to merge 3 commits into
livekit:mainfrom
Rehansanjay:fix/finally-unhandled-rejections
Open

Rehansanjay wants to merge 3 commits into
livekit:mainfrom
Rehansanjay:fix/finally-unhandled-rejections

Conversation

@Rehansanjay

@Rehansanjay Rehansanjay commented Sep 17, 2026

Copy link
Copy Markdown

Description

Fixes #2508.

promise.finally(cb) returns a new promise that rejects with the original error, or with anything cb throws. At the sites below nothing handled that derived promise, so a failing speech task, participant entrypoint, TTS request or worker task produced an unhandled promise rejection. In a job process that only reaches the debug-level handler in ipc/job_proc_lazy_main.ts; the worker process installs no handler, so Node's default applies.

Each site now handles it the way the equivalent Python code does, or the way the surrounding JS already does.

Changes Made

Site Change
voice/agent_activity.ts (3 sites) task.addDoneCallback(() => this.onPipelineReplyDone(lease)) instead of task.result.finally(...). Task runs done callbacks without exposing the task's rejection and logs a throwing callback. Python registers _on_pipeline_reply_done with add_done_callback at the same three places.
voice/speech_handle.ts Each done callback runs in its own try/catch and a failure is logged with warn, so one throwing callback no longer skips the rest or rejects the derived promise. A callback added after the handle is done goes through the same guard instead of an unguarded queueMicrotask. Python's SpeechHandle does the same.
utils.ts (Task.addDoneCallback) A callback added after the task is done now runs through the same try/catch as the completion loop, so a throwing late callback is logged instead of becoming an uncaught exception.
job.ts A rejected participant entrypoint is logged as error in participant entrypoint, as Python's _on_done does.
worker.ts (availability, termination, job request), ipc/proc_pool.ts The bookkeeping .finally now ends in a .catch that logs. A task is removed from #tasks / tasks when it settles, so the shutdown allSettled never observes its error; this log is where it surfaces. proc_pool.ts gains a logger for this.
voice/avatar/datastream_io.ts A failed streamWriter.close() in flush() is logged with warn; the writer is still cleared.
tts/tts.ts (SynthesizeStream.pushText, ChunkedStream constructor) The derived promise is handled. The metrics task's own error is already consumed in close(), and ChunkedStream emits its failure on the TTS error event before rethrowing.

Behaviour change to be aware of: an error from one of the worker or process-pool tasks is now logged instead of reaching Node as an unhandled rejection, which by default terminates the process.

Pre-Review Checklist

  • Build passes: All builds (lint, typecheck, tests) pass locally
  • AI-generated code reviewed: Removed unnecessary comments and ensured code quality
  • Changes explained: All changes are properly documented and justified above
  • Scope appropriate: All changes relate to the PR title, or explanations provided for why they're included
  • Video demo: not applicable, no user-visible behaviour in the playground

Testing

  • Automated tests added/updated (if applicable)
  • All tests pass
  • Make sure both restaurant_agent.ts and realtime_agent.ts work properly (for major changes)

New regression tests, each of which fails on main:

  • speech_handle.test.ts: a throwing done callback no longer prevents the next callback and leaves no unhandled rejection; a throwing callback added after the handle is done no longer becomes an uncaught exception.
  • utils.test.ts: a throwing done callback added after a task is done no longer becomes an uncaught exception.
  • job.test.ts: a rejecting participant entrypoint leaves no unhandled rejection.
  • tts.test.ts: a ChunkedStream whose run() fails reports through the error event and leaves no unhandled rejection.

The agent_activity.ts change relies on Task.addDoneCallback, which utils.test.ts already covers. The worker.ts, proc_pool.ts and datastream_io.ts sites have no new tests; they need a worker connection, a process pool or a room to exercise.

Locally: pnpm exec prettier --check, pnpm exec eslint and tsc --noEmit pass on the changed files, and a full agents/src run shows no failures from this change. The failures that remain locally also fail on main: amd_session_close.test.ts needs a built dist, and two run_context.test.ts filler tests are timing sensitive. utils.test.ts > Task > should handle nested tasks that complete successfully fails intermittently, about one run in three, on main as well as on this branch.

Additional Notes

This covers the .finally(...) part of #2508. The no-floating-promises rule discussed there would be a separate change.

promise.finally(cb) returns a new promise that rejects with the original
error, or with anything cb throws. Nothing handled that derived promise at
these sites, so a failing speech task, participant entrypoint, TTS request or
worker task produced an unhandled promise rejection.

Speech replies register their cleanup with Task.addDoneCallback, SpeechHandle
runs each done callback in its own try/catch, and participant entrypoint,
worker, process pool and avatar stream failures are logged.

Fixes livekit#2508

Written with AI assistance (Claude Code); reviewed and verified before pushing.
@Rehansanjay
Rehansanjay requested a review from a team as a code owner September 17, 2026 06:34
@changeset-bot

changeset-bot Bot commented Sep 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49b4613

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-meta Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

addDoneCallback queued a callback registered on an already-done handle with
queueMicrotask and no guard, so a throwing callback became an uncaught
exception. Both paths now run callbacks through runDoneCallback, which logs a
failure the same way. Python schedules the late callback with call_soon, where
the event loop logs the exception.

Written with AI assistance (Claude Code); reviewed and verified before pushing.
Task.addDoneCallback queued a callback registered on an already-done task with
queueMicrotask and no guard, so a throwing callback became an uncaught
exception. It now shares runDoneCallback with the completion loop, which logs
the failure. This is the same fix as the SpeechHandle one.

Written with AI assistance (Claude Code); reviewed and verified before pushing.
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.

Unhandled promise rejections from .finally(...) on task results, and a lint rule that would catch them

1 participant