Reap children spawned by IO.process.start - #6230
Merged
pchiusano merged 2 commits intoMay 5, 2026
Merged
Conversation
Have IO.process.start start a small background waiter for each child process so exited children are reaped even when user code drops the ProcessHandle without explicitly waiting or killing it. This keeps the existing Unison type/signature but changes runtime behavior: process cleanup is now best-effort from the runtime side rather than entirely caller-discipline. Existing explicit IO.process.wait calls should continue to observe the same exit code because System.Process waitForProcess is concurrent-safe. Add runtime coverage for the real IO_process_start foreign-call path. The test checks explicit wait behavior on all platforms, and checks dropped-handle zombie reaping on Linux and macOS. Windows runs the explicit-wait compatibility test using a concrete system executable. Also add @aroche-p to CONTRIBUTORS.markdown for the repository contributor check. Co-authored-by: Cursor <cursoragent@cursor.com>
Record passing proof attestations for the final IO.process.start reaper changes. Co-authored-by: Cursor <cursoragent@cursor.com>
aroche-p
force-pushed
the
fix/6175-reap-process-start-children
branch
from
May 4, 2026 12:18
72b9025 to
76a4e87
Compare
aryairani
approved these changes
May 4, 2026
Contributor
|
Great PR, thanks! |
Member
|
Agree, great PR! |
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.
Overview
What does this change accomplish and why?
IO.process.startgives user code aProcessHandle, but the runtime currently relies on user code to eventually callIO.process.waitorIO.process.kill. If the handle is dropped instead, an exited child process can remain unreaped.IO.process.starttype/signature, but it does change runtime behavior: the runtime now starts a small background waiter for each child so the process is reaped even if user code does not explicitly wait.Include "before and after" examples if appropriate.
IO.process.startand then discarded could remain visible as a defunct child of the UCM process.waitForProcessand reaps the child after it exits.List any Github issues that this PR closes, in closing-issues-using-keywords format.
Implementation approach and notes
The
IO_process_startforeign-call case now callsrunInteractiveProcessas before, then forks a best-effort background waiter that callswaitForProcess:waitForProcessis concurrent-safe inSystem.Process: concurrent waits serialize on the handle's internalMVarand return the same cached exit code. That means existing code that explicitly callsIO.process.waitshould keep getting the same exit code, while code that drops the handle no longer leaves the OS child unreaped.This adds one blocked Haskell thread per live child process started with
IO.process.start. That thread exits after the child exits andwaitForProcessreturns.I audited the other Haskell subprocess-spawning sites in this repo. The pager, fzf,
UCM_DIFFTOOL,UCM_MERGETOOL, history-comment editor, CLI integration tests, andIO.process.callalready usewithCreateProcess,callProcess, orreadProcessWithExitCode, so their process lifetime is already bracketed/reaped.#6175 mentions defunct children named
[unison]. I did not find an internal Haskell call site in this repo that would create those children; the general-purpose path for Unison code to spawn such children isIO.process.start.Interesting/controversial decisions
Two alternatives seemed worth calling out:
Process.with/bracket-style API could make new user code safer by lending process handles only inside a callback/body and cleaning up afterward. I did not add that here because it is a new API design and should probably be discussed separately.ForeignPtrfinalizer machinery could help here. I don't think it is the right fit:ForeignPtrfinalizers are for raw memory pointers, whileProcessHandleis a Haskell value, and GC-timed cleanup would be less responsive than a waiter tied to the child process exiting.Test coverage
Have you included tests (which could be a transcript) for this change, or is it somehow covered by existing tests?
Unison.Test.Runtime.Process.IO_process_startforeign-call path viaforeignCall.Process.waitstill succeeds on all platforms, and on Linux/macOS it checks that dropped process handles do not leave zombie children under the test process.Would you recommend improving the test coverage (either as part of this PR or as a separate issue) or do you think it’s adequate?
ps -axo ppid=,stat=, whose flags and output are not portable across all platforms. The explicit-wait compatibility test still runs elsewhere.If you only tested by hand, because that's all that's practical to do for this change, mention that. Include screenshots.
Validation performed:
unison-devbox-build-main unison-runtime:runtime-tests --fast --test --test-arguments processnix shell nixpkgs#ormolu -c ormolu --mode check unison-runtime/src/Unison/Runtime/Foreign/Function.hs unison-runtime/tests/Suite.hs unison-runtime/tests/Unison/Test/Runtime/Process.hs./scripts/proofs/tests.sh./scripts/proofs/transcripts.shunison-runtime:runtime-tests --test-arguments processon Ubuntu, macOS, and Windows: https://github.com/aroche-p/unison/actions/runs/25298817306Loose ends
The memory-growth half of #6175 is intentionally not addressed here. That looks like a separate cache-retention/heap-profiling investigation for long-lived UCM/MCP sessions.
Final checklist
.cabalfiles, make sure thepackage.yamlfiles are up-to-date instead.