Repository navigation
Conversation
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.
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.
Reply to the Devin review β both findings addressed in 8db38ccThanks for the review β both findings are valid. They are fixed by Finding 1 β cancelled sends leave unhandled inference failures: confirmedI reproduced it end-to-end before fixing. Setup: the executor started against a child Fix (
|
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, andpredict_end_of_turnwraps 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_requestsand awaits it; responses complete the future from_main_task, which iterates the IPC channel. When the process dies,_read_ipc_taskbreaks onDuplexClosedand its done-callback closes the channel, so_main_task's loop simply ends. Nothing fails the pending futures, andawait futblocks 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 itsasync for msg in ipc_chloop in atry/finallythat cleans up pending work when the loop ends:_main_tasknow rejects all pending futures withRuntimeError("the inference process exited before responding")in afinallyblock. 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.CancelledErroris not anException, soProcJobExecutor._do_inference_taskwould not catch it and would never send an error response to the job process β cancellation would just move the hang one level deeper.Testing
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 pendingdo_inference()rejects with the error within a timeout. Before the fix this test fails withTimeoutError(the call never completes after the process dies); with the fix it passes.tests/test_ipc.py,tests/test_ipc_preload.pyandtests/test_supervised_proc_memory.pypass with no regressions.