Repository navigation
Conversation
A cancelled initialize() never resolves the initialize future (its CancelledError isn't caught), so the main task never started the monitor task, the sole reader of the IPC channel. aclose() then waited forever for a ShuttingDown that nobody read, hanging the worker shutdown (ThreadJobExecutor is the default executor on Windows and forced by the devmode CLI). Fail the future from aclose() when it is still unresolved, mirroring the SupervisedProc guard, so the main task starts the monitor task and the runner acks the queued shutdown request.
β¦own-before-init Two gaps found by an automated review of the previous commit: aclose() racing a still-running (not cancelled) initialize() resolved the initialize future under the pending response read, starting the monitor task as a second reader of the duplex (a concurrent-read RuntimeError that also failed aclose()), and initialize() then crashed double-setting the already-failed future. aclose() now stops the tracked in-flight read first, and initialize() guards its future writes. aclose() without initialize() made the ShutdownRequest the runner's first message, which died on its "first message must be InitializeRequest" assert (an uncaught exception in the runner thread) while the executor reported SUCCESS. The proc client now accepts an initial ShutdownRequest and the runners exit cleanly.
|
Both findings reproduced and are fixed in 6b21603 (on top of c08335d), with deterministic regression tests for each:
New tests |
β¦ the channel aclose() stopped initialize()'s response read with a plain cancel, which could land between the frame header and its payload (the async duplex reads them with two separate readexactly calls). The header was lost while the payload stayed on the stream, so the monitor task handed the channel read the pending payload as a new frame header: decoding failed with a struct.error that escaped aclose() itself, and the ShutdownRequestAck/ShuttingDown messages were lost even though the runner exited cleanly. Let the read finish the frame it has started first (bounded at 1s, like the monitor handover in _main_task: a runner that dies closes its end of the channel and ends the read on its own). The cancel now only ever lands on a read parked on the next frame header, where nothing was consumed yet and nothing is lost.
β¦ze read Three interrupt paths could still cancel the initialize() response read between its frame header and its payload, desynchronizing the channel for the monitor task that takes it over: aclose() past its one-second grace (a slow or starved runner), an externally cancelled initialize(), and the initialize() timeout (the bare wait_for cancels the read it wraps). In each case the monitor read the pending payload as a new frame header and the decoding error escaped aclose() itself. - initialize() shields the read from the wait_for timeout and, when the read has not finished, closes the duplex instead of cancelling it: the read ends with DuplexClosed, frame-aligned. - aclose() closes the duplex past the grace instead of cancelling the read, for the same reason. - the monitor task treats a frame that fails to decode as a closed channel (bytes may already sit in the reader buffer when the read is interrupted); it breaks and resolves the shutdown futures instead of letting the decoding error escape. - the thread runner exits cleanly when the channel closes during its warm-up (thread_main now mirrors proc_main's guard), so the abrupt close cannot surface as an unhandled thread exception. Regression tests cover all three windows (tests/test_ipc.py); on the previous head each failed with the struct.error escape.
|
Follow-up on the two residual interrupt windows in 45e9056 β both reproduced, and a third one found next to them; all three are closed in d9ab64c3. The windows. The 1-second grace in
In all three, the outcome was the same failure the first revision fixed for the sub-second case: the monitor misparses the leftover payload ( The fix β close, don't cancel. Retaining the full read would reintroduce the original hang for a runner that never finishes the frame (nothing bounds the wait). Closing the duplex instead is the bounded interrupt: the read ends with
One existing test's expectation changed with this: after Tests. Three regression tests added ( |
| try: | ||
| initialized = client.initialize() | ||
| except Exception: | ||
| # the worker closed the channel during the warm-up (e.g. a close | ||
| # that could not wait for the initialize response frame): nothing | ||
| # ran, exit cleanly like proc_main instead of letting the channel | ||
| # error surface as an unhandled thread exception | ||
| logger.debug("thread runner: channel closed during initialization") | ||
| return |
There was a problem hiding this comment.
π‘ Warm-up failures lose their traceback
When warm-up raises, thread_main suppresses its exception and exits without reporting the failure. ThreadJobExecutor.initialize ignores the response error and marks the exited runner ready, so jobs fail without a warm-up traceback.
Learn more
The runner's warm-up callback runs inside _ProcClient.initialize. If it raises, the client sends InitializeResponse(error=str(e)) and re-raises. The new handler catches that failure as though the channel had closed. ThreadJobExecutor.initialize does not inspect init_res.error, so it can announce success while the runner exits. Suppressing the exception removes the traceback that previously identified the warm-up failure.
Example: A warm-up callback raises RuntimeError("model load failed"). The client sends that text to the parent and exits through this handler. The parent marks the runner ready; a job can then be dispatched to the stopped thread, with no traceback for the model-load failure.
Recommended fix: Distinguish a closed channel from failures in the warm-up callback. Check InitializeResponse.error in ThreadJobExecutor.initialize() before resolving _initialize_fut, and preserve diagnostics for unexpected failures in thread_main.
Was this helpful? React with π or π to provide feedback.
fix(ipc): unblock thread executor aclose() when initialize is cancelled
What breaks
ThreadJobExecutor.aclose()hangs forever wheninitialize()never completed β either because itwas cancelled mid-warm-up, or because it was never called at all. The worker process then never
finishes shutting down.
This is not an exotic path:
ThreadJobExecutoris the default job executor on Windows(
worker.pyfalls back toJobExecutorType.THREADthere) and is forced by the devmode CLI(
cli.py). During worker shutdown,ProcPool._main_taskteardown cancels the proc-spawn tasks; aspawn task sitting in
await proc.initialize()gets aCancelledError, itsexcept asyncio.CancelledError: passbranch runs, and thenawait proc.aclose()never returns βso
ProcPool.aclose()/Worker.aclose()hang indefinitely. It is also reproducible on thepublic API by cancelling an in-flight
initialize()and then closing the executor.Root cause
ThreadJobExecutor.initialize()resolvesself._initialize_futonly underexcept asyncio.TimeoutError,except Exceptionand the successelsebranch. ACancelledErroris aBaseException, so cancelling an in-flightinitialize()propagateswithout ever resolving the future.
_main_task()awaitsself._initialize_futbefore creating_monitor_task, which is theonly code that reads the IPC channel and resolves
_shutdown_ack_fut/_shutting_down_fut(the runner thread's ack of the queued
ShutdownRequestsits unread in the socket buffer).aclose()then waits bounded on_shutdown_ack_fut(logging "job did not ack shutdown intime") and blocks forever on
await self._shutting_down_futβ even though the runner threadexits cleanly on its own.
The process-based sibling
SupervisedProcalready guards against exactly this:aclose()checksif not self._initialize_fut.done(): await self.kill(), andkill()resolves the future("unblock the supervise task waiting on this future").
ThreadJobExecutorwas simply missing theequivalent.
The fix
Mirror the sibling guard, adapted for a runner that cannot be killed: when the initialize future
is unresolved at
aclose()time, fail it so_main_task()proceeds, starts the monitor task(the sole channel reader), and the graceful shutdown sequence can complete β the runner acks the
queued
ShutdownRequest, sendsShuttingDown, exits, andaclose()returns.This also covers a runner that was started but never initialized: previously the same unbounded
wait applied there (the main task never started the reader, so the shutdown ack could never be
observed).
Test evidence
New regression test
test_thread_aclose_after_initialize_cancelledintests/test_ipc.py. Itstarts a thread runner whose warm-up is held on an event, cancels the in-flight
initialize()task (so the cancel cannot race with a warm-up that already finished), releases the warm-up, then
calls
aclose()under an outer timeout and asserts a cleanSUCCESSshutdown.aclose()never returns β the test fails withTimeoutErrorafter waiting 5s(the runner thread had exited on its own within a fraction of a second), plus a leaked
ThreadJobExecutor._main_taskat teardown.tests/test_ipc.pysuitepasses (18 passed), as do the related
test_ipc_preload.py,test_supervised_proc_memory.py,test_drain_timeout.pyandtest_worker_connection.py.