🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Reap children spawned by IO.process.start - #6230

Merged
pchiusano merged 2 commits into
unisonweb:trunkfrom
aroche-p:fix/6175-reap-process-start-children
May 5, 2026
Merged

Reap children spawned by IO.process.start#6230
pchiusano merged 2 commits into
unisonweb:trunkfrom
aroche-p:fix/6175-reap-process-start-children

Conversation

@aroche-p

@aroche-p aroche-p commented May 3, 2026

Copy link
Copy Markdown
Contributor

Overview

  • What does this change accomplish and why?

    • IO.process.start gives user code a ProcessHandle, but the runtime currently relies on user code to eventually call IO.process.wait or IO.process.kill. If the handle is dropped instead, an exited child process can remain unreaped.
    • In long-lived UCM/MCP sessions, that can leave exited children as zombies under the host process. UCM Process Memory Growth + Defunct Children in Long-Lived Sessions #6175 reports this as one symptom alongside a separate memory-growth issue.
    • This change keeps the existing IO.process.start type/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.

    • Before: a short-lived process spawned with IO.process.start and then discarded could remain visible as a defunct child of the UCM process.
    • After: the runtime's background waiter calls waitForProcess and 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_start foreign-call case now calls runInteractiveProcess as before, then forks a best-effort background waiter that calls waitForProcess:

_ <- forkIO $ void $ waitForProcess ph

waitForProcess is concurrent-safe in System.Process: concurrent waits serialize on the handle's internal MVar and return the same cached exit code. That means existing code that explicitly calls IO.process.wait should 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 and waitForProcess returns.

I audited the other Haskell subprocess-spawning sites in this repo. The pager, fzf, UCM_DIFFTOOL, UCM_MERGETOOL, history-comment editor, CLI integration tests, and IO.process.call already use withCreateProcess, callProcess, or readProcessWithExitCode, 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 is IO.process.start.

Interesting/controversial decisions

Two alternatives seemed worth calling out:

  • A Unison-level 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.
  • I also considered whether the new ForeignPtr finalizer machinery could help here. I don't think it is the right fit: ForeignPtr finalizers are for raw memory pointers, while ProcessHandle is 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?

    • Added Unison.Test.Runtime.Process.
    • The test exercises the real IO_process_start foreign-call path via foreignCall.
    • It checks that explicit Process.wait still 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?

    • I think this is adequate for the scoped change. The zombie assertion is Linux/macOS-only because it relies on 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 process
  • nix 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.sh
  • Fork-only validation matrix for unison-runtime:runtime-tests --test-arguments process on Ubuntu, macOS, and Windows: https://github.com/aroche-p/unison/actions/runs/25298817306

Loose 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

  • Choose your PR title well: Your pull request title is what's used to create release notes, so please make it descriptive of the change itself, which may be different from the initial motivation to make the change.
  • Update your PR description if the specifics of the PR have changed over time.
  • Include transcripts or screenshots that demonstrate the changed behavior.
    • Included a runtime regression test rather than a transcript because the zombie check is OS/process-table-specific.
  • If you changed .cabal files, make sure the package.yaml files are up-to-date instead.

aroche-p and others added 2 commits May 4, 2026 04:30
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
aroche-p force-pushed the fix/6175-reap-process-start-children branch from 72b9025 to 76a4e87 Compare May 4, 2026 12:18
@aroche-p aroche-p changed the title Reap IO.process.start children in long-lived UCM sessions Reap children spawned by IO.process.start May 4, 2026
@aryairani

Copy link
Copy Markdown
Contributor

Great PR, thanks!

@pchiusano

Copy link
Copy Markdown
Member

Agree, great PR!

@pchiusano
pchiusano added this pull request to the merge queue May 5, 2026
Merged via the queue into unisonweb:trunk with commit 5734c8a May 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants