fix: Reap JobRunner subprocesses before os._exit to prevent orphans and semaphore leaks#582
fix: Reap JobRunner subprocesses before os._exit to prevent orphans and semaphore leaks#582hongquanli wants to merge 1 commit into
Conversation
main_hcs.py exits via os._exit(), which skips multiprocessing's atexit hook — the one that would normally terminate daemon children. Any JobRunner subprocess still alive at that point (its shutdown event never set because the exit-time abort join timed out, the worker died before _finish_jobs, or the non-blocking shutdown threads lost the race) was orphaned to PPID 1 and leaked its queue/event semaphores (the "resource_tracker: leaked semaphore objects" warning users see). - Add shutdown_all_job_runners(): finds live JobRunner children via multiprocessing.active_children(), runs their full shutdown() in parallel, then terminates any survivor. Called in main_hcs.py right before os._exit(). - JobRunner.shutdown() now also clears _ready_event so its semaphore is released like the other primitives. - Fix the _finish_jobs comment that claimed "the OS will terminate subprocesses anyway" — untrue under os._exit. Verified: unit tests reap a runner whose shutdown event was never set; GUI driver runs (simulation) close normally and mid-acquisition with zero orphaned children and no resource_tracker warning. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review guide (small, independent, ready)4 files, +99/−1, from What it fixes: orphaned Root cause: What to check (the whole review):
Evidence: unit test reaps a runner whose shutdown event was never set (deterministic repro of the leak); GUI driver close (normal + mid-acquisition) shows zero orphans, no warning. Live corroboration since: a crashed-pytest run this week accumulated 264 orphaned JobRunner children that poisoned the whole multiprocessing test suite machine-wide until swept — same leak, different trigger. 🤖 Generated with Claude Code |
Summary
Fixes two user-visible symptoms of the same bug: orphaned
multiprocessing spawn_mainprocesses (PPID 1) accumulating after GUI sessions, and the shutdown warningresource_tracker: There appear to be N leaked semaphore objects to clean up at shutdown.Root cause
main_hcs.pyexits viaos._exit()(deliberate — PyQt5's C++ destructor order conflicts with Python's GC). Butos._exit()also skips multiprocessing's atexit hook, the mechanism that normally terminates daemon child processes. AnyJobRunnersubprocess still alive at that moment is orphaned to PPID 1 and its queue/event semaphores leak.The per-acquisition cleanup (
_finish_jobs) shuts runners down in non-blocking daemon threads, with a comment claiming "if app exits before threads complete, OS will terminate subprocesses anyway" — untrue underos._exit(). Runners stay alive whenever their shutdown event was never set: the exit-time abort join times out (15 s cap), the worker dies before_finish_jobs, or the shutdown threads lose the race toos._exit().Fix
shutdown_all_job_runners()(job_processing.py): finds liveJobRunnerchildren viamultiprocessing.active_children(), runs their fullshutdown()in parallel (bounded), then terminates any survivor. Called inmain_hcs.pyimmediately beforeos._exit().JobRunner.shutdown()now also clears_ready_event, releasing its semaphore like the other primitives._finish_jobscomment.Testing
tests/control/core/test_job_runner_teardown.py: a runner whose shutdown event was never set is reaped (deterministic reproduction of the leak condition); no-op with no runners; safe after a prior clean shutdown.test_job_runner_shutdown/pending, backpressure, zarr writer, OME-TIFF saving).main_hcs.py's exact exit path): normal close after an acquisition and close mid-acquisition — zero orphaned children, no resource_tracker warning.🤖 Generated with Claude Code