Go version
go version go1.26.7 darwin/arm64
Output of go env in your module/workspace:
GOOS='darwin'
GOARCH='arm64'
CGO_ENABLED='1'
CC='cc'
GOROOT='/opt/homebrew/Cellar/go/1.26.7/libexec'
What did you do?
I used os/exec to start a child process and inspected SIGUSR1 with sigaction() at the very beginning of the child process. A C constructor is used so the signal state is captured before the child Go runtime installs its own handlers.
package main
/*
#include <signal.h>
static int initial_handler_is_default;
static int initial_flags;
__attribute__((constructor))
static void capture_initial_sigusr1(void) {
struct sigaction action;
if (sigaction(SIGUSR1, NULL, &action) == 0) {
initial_handler_is_default = action.sa_handler == SIG_DFL;
initial_flags = action.sa_flags;
}
}
static int get_initial_handler_is_default(void) {
return initial_handler_is_default;
}
static int get_initial_flags(void) {
return initial_flags;
}
*/
import "C"
import (
"fmt"
"os"
"os/exec"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "child" {
handlerIsDefault := C.get_initial_handler_is_default()
flags := C.get_initial_flags()
fmt.Printf("SIGUSR1 at process startup: default=%t flags=%#x\n",
handlerIsDefault != 0, uint32(flags))
if handlerIsDefault == 0 || flags != 0 {
os.Exit(1)
}
return
}
cmd := exec.Command(os.Args[0], "child")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Printf("child started with a dirty signal disposition: %v\n", err)
os.Exit(1)
}
}
Build it with cgo enabled, then compare a direct launch with a launch through os/exec:
$ go build -o signal-flags repro.go
$ ./signal-flags child
SIGUSR1 at process startup: default=true flags=0x0
$ ./signal-flags
SIGUSR1 at process startup: default=true flags=0x42
child started with a dirty signal disposition: exit status 1
On Darwin, 0x42 is SA_SIGINFO | SA_RESTART.
What did you see happen?
A process launched through os/exec starts with SIGUSR1 set to SIG_DFL, but the disposition still has SA_SIGINFO | SA_RESTART set. A direct launch starts with the same SIG_DFL disposition and no flags.
This creates contradictory state for a non-Go child runtime: SA_SIGINFO says that the sa_sigaction union member is active, but the disposition is SIG_DFL, whose union value is NULL.
Darwin's sigaction(2) documentation explicitly forbids this combination:
SA_SIGINFO — If this bit is set, the handler function is assumed to be
pointed to by the sa_sigaction member of struct sigaction [...] This bit
should not be set when assigning SIG_DFL or SIG_IGN.
The disposition created by the Go runtime has exactly the state the
documentation says not to create: SIG_DFL with SA_SIGINFO set.
This caused a real NativeAOT .NET child process launched by a Go program to crash the first time it received SIGUSR1 (I've also filed a bug on the .NET Runtime for the fact that they're naively trusting the presence of SA_SIGINFO as implying that a real sa_sigaction address is present, since the Kernel obviously doesn't enforce the prohibition) . The runtime observed SA_SIGINFO, saved the inherited sa_sigaction for signal chaining, and later called address 0x0:
EXC_BAD_ACCESS (SIGSEGV)
KERN_INVALID_ADDRESS at 0x0000000000000000
frame 0: PC 0x0
frame 1: _sigtramp
Resetting signal dispositions in the parent before Command.Start does not
work because runtime_AfterForkInChild calls clearSignalHandlers afterward
in the fork child, recreating the problematic dispositions immediately before
execve.
The downstream workaround therefore requires an exec shim. os/exec first
starts a small helper process. After that helper has crossed the problematic
fork/exec boundary, it resets the signal dispositions with sigaction and
replaces itself with the requested program using execve directly, without
another Go fork child between the reset and the final exec. An example of this
workaround is microsoft/dcp#244.
What did you expect to see?
A child process whose signal disposition is SIG_DFL should not inherit Go runtime handler flags. In this example I expected:
SIGUSR1 at process startup: default=true flags=0x0
At minimum, SA_SIGINFO should not be present when the disposition is reset to SIG_DFL.
Suspected cause
Before execve, the os/exec fork child calls runtime_AfterForkInChild, which calls clearSignalHandlers. That function resets Go-managed signals using:
Darwin's setsig unconditionally applies Go's handler flags and full signal mask, even when fn is _SIG_DFL or _SIG_IGN:
func setsig(i uint32, fn uintptr) {
var sa usigactiont
sa.sa_flags = _SA_SIGINFO | _SA_ONSTACK | _SA_RESTART
sa.sa_mask = ^uint32(0)
// ...
*(*uintptr)(unsafe.Pointer(&sa.__sigaction_u)) = fn
sigaction(i, &sa, nil)
}
The fork child therefore installs SIG_DFL together with flags intended for a real Go signal handler. After execve, the new process observes SIG_DFL with SA_SIGINFO | SA_RESTART (SA_ONSTACK is not present in the observed post-exec state).
A possible fix would be to apply those flags and mask only for real handlers:
func setsig(i uint32, fn uintptr) {
var sa usigactiont
if fn != _SIG_DFL && fn != _SIG_IGN {
sa.sa_flags = _SA_SIGINFO | _SA_ONSTACK | _SA_RESTART
sa.sa_mask = ^uint32(0)
}
// ...
}
This resembles the class of problem fixed in #75253, where stale/inapplicable sigaction flags caused a crash, although that issue involved SA_RESTORER on Linux rather than Darwin's post-exec signal state.
Go version
go version go1.26.7 darwin/arm64
Output of
go envin your module/workspace:What did you do?
I used
os/execto start a child process and inspectedSIGUSR1withsigaction()at the very beginning of the child process. A C constructor is used so the signal state is captured before the child Go runtime installs its own handlers.Build it with cgo enabled, then compare a direct launch with a launch through
os/exec:On Darwin,
0x42isSA_SIGINFO | SA_RESTART.What did you see happen?
A process launched through
os/execstarts withSIGUSR1set toSIG_DFL, but the disposition still hasSA_SIGINFO | SA_RESTARTset. A direct launch starts with the sameSIG_DFLdisposition and no flags.This creates contradictory state for a non-Go child runtime:
SA_SIGINFOsays that thesa_sigactionunion member is active, but the disposition isSIG_DFL, whose union value is NULL.Darwin's
sigaction(2)documentation explicitly forbids this combination:The disposition created by the Go runtime has exactly the state the
documentation says not to create:
SIG_DFLwithSA_SIGINFOset.This caused a real NativeAOT .NET child process launched by a Go program to crash the first time it received
SIGUSR1(I've also filed a bug on the .NET Runtime for the fact that they're naively trusting the presence ofSA_SIGINFOas implying that a realsa_sigactionaddress is present, since the Kernel obviously doesn't enforce the prohibition) . The runtime observedSA_SIGINFO, saved the inheritedsa_sigactionfor signal chaining, and later called address0x0:Resetting signal dispositions in the parent before
Command.Startdoes notwork because
runtime_AfterForkInChildcallsclearSignalHandlersafterwardin the fork child, recreating the problematic dispositions immediately before
execve.The downstream workaround therefore requires an exec shim.
os/execfirststarts a small helper process. After that helper has crossed the problematic
fork/exec boundary, it resets the signal dispositions with
sigactionandreplaces itself with the requested program using
execvedirectly, withoutanother Go fork child between the reset and the final exec. An example of this
workaround is microsoft/dcp#244.
What did you expect to see?
A child process whose signal disposition is
SIG_DFLshould not inherit Go runtime handler flags. In this example I expected:At minimum,
SA_SIGINFOshould not be present when the disposition is reset toSIG_DFL.Suspected cause
Before
execve, theos/execfork child callsruntime_AfterForkInChild, which callsclearSignalHandlers. That function resets Go-managed signals using:Darwin's
setsigunconditionally applies Go's handler flags and full signal mask, even whenfnis_SIG_DFLor_SIG_IGN:The fork child therefore installs
SIG_DFLtogether with flags intended for a real Go signal handler. Afterexecve, the new process observesSIG_DFLwithSA_SIGINFO | SA_RESTART(SA_ONSTACKis not present in the observed post-exec state).A possible fix would be to apply those flags and mask only for real handlers:
This resembles the class of problem fixed in #75253, where stale/inapplicable
sigactionflags caused a crash, although that issue involvedSA_RESTORERon Linux rather than Darwin's post-exec signal state.