This is separate from #1196. It reproduces on CPython 3.12 today and is not fixed by the sys.is_finalizing() join guard.
Symptom
ThreadPrefetchDatasetIterator.close() hangs when its producer is blocked inside parent.__next__().
Mechanism (mainline before the fix)
close sets _closed, calls _stop_prefetch, then parent.close().
_stop_prefetch sets the stop event, drains the buffer, and joins the producer with no timeout.
- The producer only checks the stop event at the top of its loop. Inside
parent.__next__() the stop event does nothing.
parent.close() runs only after the join, so a parent that unblocks only when closed never runs. Deadlock.
Evidence
Source yields one element, then blocks in __next__ until _closed is set. Consumer takes one element, raises, keeps the iterator, calls close().
| Pipeline |
Hang rate |
Single-level ThreadPrefetch |
10 / 10 |
Nested ThreadPrefetch -> map -> ThreadPrefetch (device_put shape) |
10 / 10 |
Not nesting-only. Nesting is how real pipelines surface it; the defect is join-before-cancel on every ThreadPrefetch node. The full-buffer exception-path put did not hang alone (_clear_buffer mitigates it).
Regression test
test_close_does_not_hang_when_producer_blocked_in_parent, single-level and nested. Subprocess-isolated with a hard join timeout. Fails at pristine base on behaviour; passes with two-phase close.
Fix direction
request_stop(): non-blocking cancel (stop event, buffer wake, parent propagation, no join).
- Explicit
close(): request_stop, then parent.close(), then join (still skips join when sys.is_finalizing()).
__del__ calls request_stop only, never join.
Question
Should non-blocking request_stop() live on base DatasetIterator, or stay local to ThreadPrefetch? Explicit close() would remain the blocking path that joins.
Second bug (not fixed here)
Parent StopIteration is a BaseException, so the producer except Exception never puts end-of-stream on the buffer and a consumer blocked in get is not woken. Not required for the blocked-parent tests; left as follow-up.
This is separate from #1196. It reproduces on CPython 3.12 today and is not fixed by the
sys.is_finalizing()join guard.Symptom
ThreadPrefetchDatasetIterator.close()hangs when its producer is blocked insideparent.__next__().Mechanism (mainline before the fix)
closesets_closed, calls_stop_prefetch, thenparent.close()._stop_prefetchsets the stop event, drains the buffer, and joins the producer with no timeout.parent.__next__()the stop event does nothing.parent.close()runs only after the join, so a parent that unblocks only when closed never runs. Deadlock.Evidence
Source yields one element, then blocks in
__next__until_closedis set. Consumer takes one element, raises, keeps the iterator, callsclose().ThreadPrefetchThreadPrefetch -> map -> ThreadPrefetch(device_put shape)Not nesting-only. Nesting is how real pipelines surface it; the defect is join-before-cancel on every ThreadPrefetch node. The full-buffer exception-path
putdid not hang alone (_clear_buffermitigates it).Regression test
test_close_does_not_hang_when_producer_blocked_in_parent, single-level and nested. Subprocess-isolated with a hard join timeout. Fails at pristine base on behaviour; passes with two-phase close.Fix direction
request_stop(): non-blocking cancel (stop event, buffer wake, parent propagation, no join).close():request_stop, thenparent.close(), then join (still skips join whensys.is_finalizing()).__del__callsrequest_stoponly, never join.Question
Should non-blocking
request_stop()live on baseDatasetIterator, or stay local to ThreadPrefetch? Explicitclose()would remain the blocking path that joins.Second bug (not fixed here)
Parent
StopIterationis aBaseException, so the producerexcept Exceptionnever puts end-of-stream on the buffer and a consumer blocked ingetis not woken. Not required for the blocked-parent tests; left as follow-up.