Fix memory leak: track live children with one shutdown handler - #15
Open
flavio-fernandes wants to merge 1 commit into
Open
Fix memory leak: track live children with one shutdown handler#15flavio-fernandes wants to merge 1 commit into
flavio-fernandes wants to merge 1 commit into
Conversation
Author
|
@mortoray — this is ready for review. The final version is one amended, squashed commit (ff94d43) with all 25 tests passing on CPython 3.11.16 and 3.14.7 and no changes to the existing Group API. The reader block is flattened and now guarantees its completion sentinel even when on_error raises; the PR description contains the condensed problem, implementation, portability, and verification summary. |
flavio-fernandes
marked this pull request as draft
September 8, 2026 18:22
flavio-fernandes
force-pushed
the
fix/atexit-handler-leak
branch
from
September 9, 2026 01:33
c019696 to
674093e
Compare
flavio-fernandes
marked this pull request as ready for review
September 9, 2026 01:38
flavio-fernandes
added a commit
to flavio-fernandes/mqtt2cmd
that referenced
this pull request
Sep 9, 2026
Group._run_impl registered an atexit handler per spawned process and never released it. The handler closed over the Popen handle, so the registry kept a strong reference to every process a group had ever run, uncollectable even after the child exited. On this Pi Zero W, dispatching ~18,000 commands a day, the mqtt2cmd dispatcher grew ~655 bytes per command -- about 11 MB/day, exhausting a 437 MB box every couple of weeks. Replace the per-child closures with one module-level handler that tracks only live handles. Unregistering per child is not sufficient: before CPython 3.14 atexit.unregister frees the callback but never reclaims its registry slot, so the array still grows by one pointer per command. Drop a handle only once the child has actually exited, not when its output reaches EOF -- a command that closes or replaces both stdout and stderr while still running hits EOF immediately, and releasing there would orphan it at interpreter exit. Make the reader cleanup exception-safe. stdin.close() can raise BrokenPipeError; escaping there skipped both the decrement of self.waiting, leaving readlines() waiting forever, and the discard, stranding the handle for the interpreter's life. One outer try/finally now covers closing, the decrement, the wait and the discard. Reset inherited state in POSIX fork children. Waiting for real exit means a reader thread holds each handle's Popen._waitpid_lock for the child's life, so a forked child inherits it locked, poll() cannot read the real status, and terminate() would signal a process the child does not own. A PID guard makes the handler inert in any process that did not create the registry, and an at-fork hook re-arms tracking where the API exists. This mirrors the upstream fix in mortoray/shelljob#15, from which proc.py is vendored; the shared code is line-for-line identical apart from indentation. Add regression coverage for collection, registry churn, stream errors, output-closing children, fork ownership and re-arming, and platforms without register_at_fork. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace per-child atexit closures with one module-level handler that tracks only live Popen handles. Make reader cleanup exception-safe, always emit the completion sentinel, and retain handles until the child actually exits. Reset inherited handles and locks in POSIX fork children, retain the PID safety guard, and keep imports portable on non-forking platforms. Require Python 3.7 or newer. Add regression coverage for collection, registry churn, stream and callback errors, output-closing children, fork ownership and re-arming, and platforms without register_at_fork. Closes mortoray#14
flavio-fernandes
force-pushed
the
fix/atexit-handler-leak
branch
from
September 9, 2026 02:08
674093e to
ff94d43
Compare
flavio-fernandes
added a commit
to flavio-fernandes/mqtt2cmd
that referenced
this pull request
Sep 9, 2026
Group._run_impl registered an atexit handler per spawned process and never released it. The handler closed over the Popen handle, so the registry kept a strong reference to every process a group had ever run, uncollectable even after the child exited. On this Pi Zero W, dispatching ~18,000 commands a day, the mqtt2cmd dispatcher grew ~655 bytes per command -- about 11 MB/day, exhausting a 437 MB box every couple of weeks. Replace the per-child closures with one module-level handler that tracks only live handles. Unregistering per child is not sufficient: before CPython 3.14 atexit.unregister frees the callback but never reclaims its registry slot, so the array still grows by one pointer per command. Drop a handle only once the child has actually exited, not when its output reaches EOF -- a command that closes or replaces both stdout and stderr while still running hits EOF immediately, and releasing there would orphan it at interpreter exit. Make the reader cleanup exception-safe. stdin.close() can raise BrokenPipeError; escaping there skipped both the decrement of self.waiting, leaving readlines() waiting forever, and the discard, stranding the handle for the interpreter's life. A single try/except/finally now covers the completion sentinel, closing, the decrement, the wait and the discard, so no path can skip them. Reset inherited state in POSIX fork children. Waiting for real exit means a reader thread holds each handle's Popen._waitpid_lock for the child's life, so a forked child inherits it locked, poll() cannot read the real status, and terminate() would signal a process the child does not own. A PID guard makes the handler inert in any process that did not create the registry, and an at-fork hook re-arms tracking where the API exists. This mirrors the upstream fix in mortoray/shelljob#15, from which proc.py is vendored. The shared code is line-for-line identical apart from indentation; the only divergences are upstream's encoding-aware readline sentinel and its on_error callback, neither of which exists here. Add regression coverage for collection, registry churn, stream errors, output-closing children, fork ownership and re-arming, and platforms without register_at_fork. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
|
/assign @mortoray |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes unbounded handle retention in long-running Group users. The previous implementation registered one atexit closure per child, keeping completed Popen objects reachable for the lifetime of the interpreter.
This change:
Regression coverage includes collectability, atexit registry churn, stream and callback failures, children outliving output, inherited handle isolation, child lock reset and re-arming, and import without register_at_fork.
Verification: 25 tests passed on CPython 3.11.16 and 3.14.7.
Closes #14