Skip to content

fix(inworld): keep a reference to the prewarm task and cancel it on close - #7050

Merged
davidzhao merged 1 commit into
livekit:mainfrom
Rehansanjay:fix/inworld-prewarm-task-reference
Sep 2, 2026
Merged

fix(inworld): keep a reference to the prewarm task and cancel it on close#7050
davidzhao merged 1 commit into
livekit:mainfrom
Rehansanjay:fix/inworld-prewarm-task-reference

Conversation

@Rehansanjay

@Rehansanjay Rehansanjay commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The bug

TTS.prewarm() throws away the task it creates:

def prewarm(self) -> None:
    asyncio.create_task(self._prewarm_impl())

Two consequences.

The task is only weakly referenced. The event loop does not keep a strong reference to a bare task, so it can be garbage collected mid-execution and the prewarm silently never happens.

The ordering one is worse. _prewarm_impl calls _get_pool, which builds a fresh _ConnectionPool whenever self._pool is None:

async def _get_pool(self) -> _ConnectionPool:
    async with self._pool_lock:
        if self._pool is None or self._pool._closed:
            self._pool = _ConnectionPool(...)
        return self._pool

and aclose() sets self._pool = None after closing it. So a prewarm still in flight when the TTS is closed recreates the pool after close, leaving a live websocket connection that nothing owns and nothing will ever close.

The fix

Store the task and cancel it at the top of aclose(), before the pool is torn down.

This is the pattern already in the tree. openai/tts.py hand-rolls its prewarm the same way and holds the task:

def prewarm(self) -> None:
    ...
    self._prewarm_task = asyncio.create_task(_prewarm())

async def aclose(self) -> None:
    if self._prewarm_task:
        await aio.cancel_and_wait(self._prewarm_task)

and utils.ConnectionPool.prewarm() — used by the fifteen other plugins — keeps self._prewarm_task and checks whether it is still running before starting another.

Inworld was the only plugin calling asyncio.create_task in prewarm() without holding on to the result.

Checks

ruff check and ruff format --check pass on the changed file. No behaviour change on the success path: prewarm still runs in the background and is still fire-and-forget from the caller's point of view.


Found with a small AST pass over the repo looking for create_task calls whose result is discarded. Happy to send the other instances separately if useful.

…lose

`prewarm()` discarded the task it created. Two things follow from that.

The task is only weakly referenced by the event loop, so it can be
garbage collected mid-execution and the prewarm silently never happens.

The worse one is ordering. `_prewarm_impl` calls `_get_pool`, which
builds a new `_ConnectionPool` whenever `self._pool` is None — and
`aclose()` sets it to None after closing. A prewarm still in flight when
the TTS is closed therefore recreates the pool afterwards, leaving a live
connection that nothing owns and nothing will close.

Store the task and cancel it at the top of `aclose()`, which is what
`openai/tts.py` does with its own hand-rolled prewarm, and what
`utils.ConnectionPool.prewarm()` does for the fifteen plugins that use
it. Inworld was the only plugin calling `asyncio.create_task` in
`prewarm()` without holding on to the result.
@Rehansanjay
Rehansanjay requested a review from a team as a code owner August 31, 2026 06:28

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

@Rehansanjay

Copy link
Copy Markdown
Contributor Author

Bumping — this is the same leak you merged in #7023, one plugin over.

inworld's TTS.prewarm() discards its task. Beyond the weak-reference hazard, there is an ordering bug next to it: _prewarm_impl calls _get_pool, which builds a new _ConnectionPool whenever self._pool is None, and aclose() sets it to None after closing. A prewarm still in flight when the TTS closes therefore rebuilds the pool afterwards, leaving a live connection that nothing owns and nothing will close.

The fix follows what is already in the tree: openai/tts.py stores its hand-rolled prewarm task and cancels it in aclose(), and utils.ConnectionPool.prewarm() does the same for the fifteen plugins that use it. inworld was the only plugin doing neither.

One file, +10/-2, all checks green, no open review threads.

cc @davidzhao — same shape as #7023.

@davidzhao
davidzhao merged commit c983ed1 into livekit:main Sep 2, 2026
16 checks passed
Rehansanjay added a commit to Rehansanjay/agents that referenced this pull request Sep 2, 2026
Follow-up to livekit#7050. `prewarm()` overwrites `_prewarm_task` unconditionally,
so a second call drops the only reference to a task still in flight, and
`aclose()` cancels just the latest one — leaving the earlier attempt free
to build a connection pool after shutdown, which is the leak livekit#7050 set out
to close.

Starts a task only when there is no live one, matching the soniox plugin
and `utils.ConnectionPool.prewarm`.
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.

2 participants