fix: reset inherited signal dispositions for forked processes on macOS - #244
Merged
David Negstad (danegsta) merged 2 commits intoAug 19, 2026
Merged
Conversation
On macOS, processes started by 'dcp fork-process' inherited the Go runtime's signal handler flags (SA_SIGINFO|SA_ONSTACK|SA_RESTART). Other language runtimes do not expect to start with those flags set, and NativeAOT .NET binaries crash with SIGSEGV the first time they take a GC suspension via SIGUSR1. Clearing the dispositions in 'fork-process' before starting the child does not help, because the Go runtime restores its own dispositions in the forked child before it reaches execve. The reset has to happen in the process that calls exec, so 'fork-process' now routes the child through a hidden 'fork-process-exec' command that clears the dispositions and then execs the requested program. Since execve keeps the process ID, the reported PID, session, standard streams, and exit code all stay the same. The reset is a no-op on other platforms, where children are started directly as before. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcd61592-fa3e-49ab-8efc-d21136bb147d
David Negstad (danegsta)
requested review from
Karol Zadora-Przylecki (karolz-ms)
and
a balanced review from Copilot
August 19, 2026 23:07
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a macOS exec shim to clear Go runtime signal flags before launching forked processes.
Changes:
- Adds Darwin-specific signal disposition reset logic.
- Routes macOS forked processes through a hidden exec command.
- Adds structural unit tests.
The approach has correctness regressions around ignored signals, launch failures, and exit status reporting.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/process/signal_disposition_other.go |
Adds no-op implementations for non-Darwin platforms. |
pkg/process/signal_disposition_darwin.go |
Implements Darwin signal disposition resets. |
pkg/process/signal_disposition_darwin_test.go |
Tests Darwin disposition resets. |
internal/dcpproc/commands/root.go |
Registers the exec shim with dcpproc. |
internal/dcpproc/commands/fork_process.go |
Routes children through the shim on Darwin. |
internal/dcpproc/commands/fork_process_test.go |
Tests shim command rewriting. |
internal/dcpproc/commands/fork_process_exec.go |
Implements the hidden exec shim. |
internal/dcp/commands/root.go |
Registers the exec shim with dcp. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Karol Zadora-Przylecki (karolz-ms)
approved these changes
Aug 19, 2026
Routing the child through the exec shim made every start look successful, because the process being started became dcp itself, which always exists and is executable. A program that could not be run was reported as started and its PID was written to stdout, even though the process exited immediately. Commands are given absolute paths, which skip the PATH lookup that used to catch this before the child was started. The shim now reports the outcome of the exec on an inherited descriptor: a successful execve closes it, and a failure sends the errno first. 'fork-process' waits for that before reporting the PID, so a program that cannot be executed is once again a start failure carrying the original reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fcd61592-fa3e-49ab-8efc-d21136bb147d
David Negstad (danegsta)
enabled auto-merge (squash)
August 19, 2026 23:33
Member
Author
|
/backport to release/0.25 |
|
Started backporting to release/0.25: https://github.com/microsoft/dcp/actions/runs/32313789934 |
|
David Negstad (@danegsta) backport PR couldn't be created automatically, please create the backport PR manually! |
David Negstad (danegsta)
deleted the
danegsta-reset-signal-dispositions-fork
branch
August 19, 2026 23:51
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.
On macOS, processes started by
dcp fork-processinherit the Go runtime's signal handler flags (SA_SIGINFO|SA_ONSTACK|SA_RESTART). Other language runtimes do not expect to start with those flags set. NativeAOT .NET binaries in particular crash withSIGSEGVat address0x0the first time they take a GC suspension viaSIGUSR1, which made the Aspire CLI fail deterministically when launched throughfork-process.Approach
The obvious fix, clearing the dispositions in
fork-processbefore starting the child, does not work. I verified this empirically: the parent's signal table is clean both before and after the fork, yet the child still comes up with0x42on all 25 resettable signals. The Go runtime restores its own dispositions in the forked child before it reachesexecve, so the reset has to happen in the process that actually calls exec.So
fork-processnow redirects the child through a hiddenfork-process-execcommand, which clears the dispositions and thensyscall.Execs the requested program. Becauseexecvepreserves the process ID, every existing semantic is retained: reported PID,setsid, standard streams, and exit code propagation all behave exactly as before.The behavior is gated on
process.SignalDispositionsLeakToChildren(), which returnstrueonly on darwin. On every other platform children are started directly, unchanged.Notes for reviewers
A few things worth a careful look:
CGO_ENABLED ?= 0in the Makefile), so the reset uses a rawsyscall.Syscall(SYS_SIGACTION, ...). This is safe here specifically because we only ever installSIG_DFL, and thesa_trampfield is unused when no handler is being installed.struct __sigaction(handler, tramp, mask, flags) while the old/returned action usesstruct sigaction(handler, mask, flags, no tramp). Getting this wrong corrupts memory, so the two are modeled as separate types.fork-process's own signal handling that--monitordepends on.cmd.Erris checked before rewriting the command so that a path lookup failure still surfaces the original "executable file not found in $PATH" error instead of a confusing shim failure.--exec-pathis passed as a separate flag rather than reusingargs[0], so the child keeps the caller's originalargv[0](for examplenode, not/usr/local/bin/node).Testing
Automated coverage here is necessarily structural rather than end-to-end: a Go child cannot observe its own inherited
sa_flags, because the runtime re-arms nearly every signal during init before any test code runs. The signals Go leaves alone are clean in both the good and bad cases, so they are useless as probes. Verifying the actual flags required a small C dumper run out-of-band.ResetSignalDispositions(re-execs a helper, since the reset disables Go's own signal handling) and for theuseExecShimcommand rewriting.pidmatches the reported PID,sid == pid,pgid == pid,SIGUSR1flags=0,dirtycount=0, and exit codes propagate.test/aspire/run-regression.shpasses with the fixed build (all five resources healthy).make lintis clean, andgo build/go vetpass for linux and windows in addition to darwin.One caveat on the regression script: it passes with an unfixed binary too, so it confirms no regression but is not by itself a repro of the original crash. The crash path was confirmed separately against the NativeAOT Aspire CLI.