Skip to content

fix: reset inherited signal dispositions for forked processes on macOS - #244

Merged
David Negstad (danegsta) merged 2 commits into
mainfrom
danegsta-reset-signal-dispositions-fork
Aug 19, 2026
Merged

fix: reset inherited signal dispositions for forked processes on macOS#244
David Negstad (danegsta) merged 2 commits into
mainfrom
danegsta-reset-signal-dispositions-fork

Conversation

@danegsta

Copy link
Copy Markdown
Member

On macOS, processes started by dcp fork-process inherit 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 with SIGSEGV at address 0x0 the first time they take a GC suspension via SIGUSR1, which made the Aspire CLI fail deterministically when launched through fork-process.

Approach

The obvious fix, clearing the dispositions in fork-process before 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 with 0x42 on all 25 resettable signals. The Go runtime restores its own dispositions in the forked child before it reaches execve, so the reset has to happen in the process that actually calls exec.

So fork-process now redirects the child through a hidden fork-process-exec command, which clears the dispositions and then syscall.Execs the requested program. Because execve preserves 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 returns true only on darwin. On every other platform children are started directly, unchanged.

Notes for reviewers

A few things worth a careful look:

  • cgo is unavailable (CGO_ENABLED ?= 0 in the Makefile), so the reset uses a raw syscall.Syscall(SYS_SIGACTION, ...). This is safe here specifically because we only ever install SIG_DFL, and the sa_tramp field is unused when no handler is being installed.
  • The darwin struct layouts differ by direction. The new action uses struct __sigaction (handler, tramp, mask, flags) while the old/returned action uses struct sigaction (handler, mask, flags, no tramp). Getting this wrong corrupts memory, so the two are modeled as separate types.
  • The parent-side reset had to be removed rather than merely superseded, since it would have disabled fork-process's own signal handling that --monitor depends on.
  • cmd.Err is 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-path is passed as a separate flag rather than reusing args[0], so the child keeps the caller's original argv[0] (for example node, 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.

  • Unit tests for ResetSignalDispositions (re-execs a helper, since the reset disables Go's own signal handling) and for the useExecShim command rewriting.
  • Manual verification against a release build with a C dumper: pid matches the reported PID, sid == pid, pgid == pid, SIGUSR1flags=0, dirtycount=0, and exit codes propagate.
  • test/aspire/run-regression.sh passes with the fixed build (all five resources healthy).
  • make lint is clean, and go build / go vet pass 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.

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
Comment thread internal/dcpproc/commands/fork_process.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/dcpproc/commands/fork_process.go
Comment thread pkg/process/signal_disposition_darwin.go
Comment thread internal/dcpproc/commands/fork_process_exec.go
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
@danegsta
David Negstad (danegsta) enabled auto-merge (squash) August 19, 2026 23:33
@danegsta

Copy link
Copy Markdown
Member Author

/backport to release/0.25

@github-actions

Copy link
Copy Markdown

Started backporting to release/0.25: https://github.com/microsoft/dcp/actions/runs/32313789934

@github-actions

Copy link
Copy Markdown

David Negstad (@danegsta) backport PR couldn't be created automatically, please create the backport PR manually!

Open backport PR into release/0.25.

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