Skip to content

os/exec: Darwin child inherits SA_SIGINFO/SA_RESTART flags with a SIG_DFL disposition #81009

Description

@danegsta

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:

setsig(i, _SIG_DFL)

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions