Skip to content

fix(ipc): fail pending inference requests when the inference process dies - #7893

Open
BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/inference-proc-death-hangs-requests
Open

BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/inference-proc-death-hangs-requests

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

What breaks

When the inference process dies while a request is in flight (crash, OOM kill, manual kill), every pending InferenceProcExecutor.do_inference() call hangs forever. The calling job process waits for a response that can never arrive. The worker starts the inference executor once and does not respawn it, so those in-flight calls stay blocked indefinitely at the executor API level. (Today the only runner using this path is the turn-detector EOU runner, and predict_end_of_turn wraps its call in a 3s timeout that the agent already catches and logs β€” so the current user-visible symptom there is a bounded stall; the unbounded hang bites any caller without its own timeout, and the rejection this PR adds is the correct signal either way.)

Root cause

do_inference() registers a future in _active_requests and awaits it; responses complete the future from _main_task, which iterates the IPC channel. When the process dies, _read_ipc_task breaks on DuplexClosed and its done-callback closes the channel, so _main_task's loop simply ends. Nothing fails the pending futures, and await fut blocks forever. The same is true when the supervisor tears the executor down while requests are in flight.

Fix

Follows the process-death idiom already used by ProcJobExecutor._main_task, which wraps its async for msg in ipc_ch loop in a try/finally that cleans up pending work when the loop ends:

  • _main_task now rejects all pending futures with RuntimeError("the inference process exited before responding") in a finally block. This covers both process death (the read loop ends on channel close) and teardown (the supervisor cancels the task).
  • do_inference() fails fast with the same error once requests are closed, closing the small window where a request could still be registered after the read loop has ended but before the duplex reports the process as gone. Registering + sending and the cleanup each run as one atomic event-loop step, so every request either gets its response or a rejection β€” it can never be silently dropped.
  • The pending requests are failed rather than cancelled on purpose: CancelledError is not an Exception, so ProcJobExecutor._do_inference_task would not catch it and would never send an error response to the job process β€” cancellation would just move the hang one level deeper.

Testing

  • Added tests/test_ipc.py::test_inference_proc_death_fails_pending_requests: starts the executor, round-trips one request, issues a request served by a blocking runner, kills the process mid-request, and asserts the pending do_inference() rejects with the error within a timeout. Before the fix this test fails with TimeoutError (the call never completes after the process dies); with the fix it passes.
  • Full tests/test_ipc.py, tests/test_ipc_preload.py and tests/test_supervised_proc_memory.py pass with no regressions.

When the inference process dies (crash, OOM kill, manual kill) with a
request in flight, do_inference() waited forever for a response that
could never arrive: nothing failed the pending futures when the IPC read
loop ended, so the agent hung with no error.

Reject the pending requests when _main_task ends (the read task is the
sole IPC reader; once it ends the process is gone or being torn down),
and fail fast in do_inference() for requests arriving after that.
@BlueX888
BlueX888 requested a review from a team as a code owner October 17, 2026 05:44
devin-ai-integration[bot]

This comment was marked as resolved.

A cancellation while do_inference() was blocked sending a large request
(stuck in the duplex writer's drain()) bypassed the except Exception
cleanup because CancelledError inherits from BaseException. The future
stayed registered with no waiter, so _fail_pending_requests() later
completed it with an exception nobody retrieved, logging an asyncio
"Future exception was never retrieved" warning when the process exited.

Catch BaseException around the send so cancellations also deregister
the request, retrieve the exception when the pending-requests cleanup
had already rejected the future (the process died mid-send, the send
error is raised instead), and drop cancelled requests from
_active_requests with a done callback so cancellation while waiting for
the response leaves no dead entry either.
@BlueX888

Copy link
Copy Markdown
Contributor Author

Reply to the Devin review β€” both findings addressed in 8db38cc

Thanks for the review β€” both findings are valid. They are fixed by
8db38cc48 ("fix(ipc): deregister inference requests when their send is cancelled"),
pushed to this branch.

Finding 1 β€” cancelled sends leave unhandled inference failures: confirmed

I reproduced it end-to-end before fixing. Setup: the executor started against a child
that completes the InitializeRequest handshake and then never reads the IPC socket
again (a hung inference process). Two 4MB requests are sent; against the stalled peer
the second send blocks inside the duplex writer's drain() (confirmed via the task's
suspension point at the channel.asend_message call in do_inference). Cancelling that
task mid-send raised CancelledError out of do_inference, bypassed except Exception
exactly as described, and left its future registered in _active_requests with no
waiter. After the process was killed, _fail_pending_requests() completed it with
RuntimeError("the inference process exited before responding") and, once the last
reference dropped, asyncio logged:

Future exception was never retrieved
future: <Future finished exception=RuntimeError('the inference process exited before responding')>

Fix (livekit-agents/livekit/agents/ipc/inference_proc_executor.py)

  • the send cleanup now catches BaseException instead of Exception, so a cancellation
    while the send is blocked deregisters (and cancels) the request instead of orphaning
    the future;
  • if the pending-requests cleanup had already rejected the future while the send was
    still blocked (process died mid-send; the caller then gets the send error instead),
    the exception is retrieved so asyncio never reports it as unretrieved;
  • a done callback drops cancelled requests from _active_requests, so cancellation
    while waiting for the response (the second window in your recommendation) also
    leaves no orphaned entry.

Regression tests (tests/test_ipc.py)

Both deterministic β€” no monkeypatching, they drive the real IPC socket against the
stalled child described above:

  • test_cancelled_inference_send_leaves_no_pending_request β€” cancels a large request
    mid-drain(), asserts the request is deregistered, then kills the process and
    asserts no "was never retrieved" warning is logged;
  • test_cancelled_inference_wait_leaves_no_pending_request β€” cancels while awaiting
    the response and asserts the request is deregistered.

Both fail on current main (AssertionError: assert 2 == 1 and assert 1 == 0 β€” the
orphaned future is the 2/1) and pass at 8db38cc48:

  • red (main ceb3d598 + final tests): implementer and reviewer receipts, exit 1;
  • green (8db38cc48): tests/test_ipc.py, tests/test_ipc_preload.py,
    tests/test_supervised_proc_memory.py β†’ 43 passed, 1 skipped (implementer run);
    tests/test_ipc.py β†’ 20 passed (independent reviewer run).

Finding 2 β€” pending-request cleanup lacks required documentation: fixed

_fail_pending_requests() now carries the suggested docstring verbatim:

def _fail_pending_requests(self) -> None:
    """Reject outstanding inference requests after the IPC reader stops."""

The runtime behavior is unchanged.

This branch has not been deployed

No deployments
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