Area: NativeAOT runtime — dotnet/runtime, NativeAOT runtime, src/coreclr/nativeaot/Runtime/unix/PalUnix.cpp (ActivationHandler, g_previousActivationHandler)
Summary
A NativeAOT app that is started via execve/posix_spawn from a process that had installed an
SA_SIGINFO handler for the activation signal crashes with SIGSEGV at pc=0 the first time the runtime
sends itself the activation signal (any GC thread suspension / hijack).
Why
On the macOS version below, execve resets a caught signal to SIG_DFL but keeps the SA_SIGINFO bit.
In the new image, sigaction(SIGUSR1, NULL, &old) reports {sa_handler = 0, sa_flags = SA_SIGINFO|SA_RESTART} (0x42).
The runtime saves that as the previous action at startup. ActivationHandler then chains to the previous
action; because SA_SIGINFO is set it tail-calls sa_sigaction (== 0) without first checking for
SIG_DFL/SIG_IGN. Result: pc=0, lr=_sigtramp+56, x0=30 (SIGUSR1), x8=0x42 (the flags byte just loaded).
The same pattern probably applies to other chained signals (SIGSEGV chaining).
Environment
- macOS 27.0.1 (26A434), Darwin 27.0.0, Apple Silicon (Mac16,8)
- .NET SDK 10.0.401,
microsoft.netcore.app.runtime.nativeaot.osx-arm64 10.0.12
- Not tested on other macOS versions. Linux clears sa_flags on exec, so this likely does not reproduce there.
Repro
probe.c (below) prints sigaction state for SIGUSR1/SIGSEGV. cc probe.c -o probe
- run directly:
sig 30 handler=0x0 flags=0x0
execer2.c (below) installs an SA_SIGINFO|SA_RESTART handler for SIGUSR1, then execve()s its argument.
cc execer2.c -o execer2
./execer2 1 ./probe -> sig 30 handler=0x0 flags=0x42 (plain parent ./execer2 0 ./probe -> flags=0x0)
- Publish the trivial NativeAOT app (sources below, mode
idle) with
dotnet publish -c Release -r osx-arm64 -o out, then:
./execer2 1 out/sigtest idle & # pid P
sleep 2; kill -USR1 P # the process dies with SIGSEGV (exit 139), pc=0
With ./execer2 0 ... the process survives. Observed 3/3 vs 0/3. Without the external kill it also
crashes on its own once a GC suspension occurs.
Expected
The runtime should not call a NULL handler: check sa_sigaction/sa_handler for SIG_DFL/SIG_IGN
before chaining, regardless of SA_SIGINFO.
Notes
- Possibly also an OS issue (exec keeping SA_SIGINFO on a SIG_DFL disposition); worth a separate Apple Feedback.
- Our workaround in the app: spawn with POSIX_SPAWN_SETSIGDEF (+ SETEXEC for in-place replacement) so the
kernel resets the dispositions and flags.
Sources
probe.c
#include <signal.h>
#include <stdio.h>
int main(void){ int sigs[]={SIGUSR1,SIGUSR2,SIGINT,SIGCHLD,SIGSEGV};
for(int i=0;i<5;i++){ struct sigaction o; sigaction(sigs[i],NULL,&o);
printf("sig %2d handler=%p flags=0x%x\n",sigs[i],(void*)o.sa_handler,o.sa_flags);} return 0;}
execer2.c
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
/* usage: execer2 <0|1> path args... ; 1 = install SA_SIGINFO handler on SIGUSR1 first (like a .NET parent), then execve */
static void h(int s, siginfo_t*i, void*c){}
extern char **environ;
int main(int argc,char**argv){ if(argv[1][0]=='1'){ struct sigaction a; memset(&a,0,sizeof a); a.sa_sigaction=h; a.sa_flags=SA_SIGINFO|SA_RESTART; sigaction(SIGUSR1,&a,NULL);}
execve(argv[2],argv+2,environ); perror("execve"); return 1;}
sigtest.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
Program.cs
using System.Diagnostics;
var stop = false;
var mode = args.Length > 0 ? args[0] : "spawn";
for (int t = 0; t < 4; t++)
new Thread(() => { var l = new List<byte[]>(); while (!stop) { l.Add(new byte[1000]); if (l.Count > 20000) l.Clear(); if (l.Count % 5000 == 0) GC.Collect(0); } }) { IsBackground = true }.Start();
if (mode == "reg")
new Thread(() => { while (!stop) { using var r = System.Runtime.InteropServices.PosixSignalRegistration.Create(System.Runtime.InteropServices.PosixSignal.SIGWINCH, c => { }); } }) { IsBackground = true }.Start();
if (mode == "pool")
new Thread(() => { while (!stop) { var ts = new Task[64]; for (int i = 0; i < ts.Length; i++) ts[i] = Task.Run(() => { Thread.Sleep(20); var x = new byte[100000]; GC.KeepAlive(x); }); Task.WaitAll(ts); ThreadPool.SetMinThreads(2, 2); } }) { IsBackground = true }.Start();
var sw = Stopwatch.StartNew();
int n = 0;
while (sw.Elapsed.TotalSeconds < 60)
{
if (mode == "spawn")
{
using var p = Process.Start(new ProcessStartInfo("/usr/bin/true") { UseShellExecute = false })!;
p.WaitForExit();
}
else Thread.Sleep(1);
n++;
}
stop = true;
Console.WriteLine($"OK {n}");
Area: NativeAOT runtime — dotnet/runtime, NativeAOT runtime,
src/coreclr/nativeaot/Runtime/unix/PalUnix.cpp(ActivationHandler,g_previousActivationHandler)Summary
A NativeAOT app that is started via
execve/posix_spawnfrom a process that had installed anSA_SIGINFOhandler for the activation signal crashes with SIGSEGV at pc=0 the first time the runtimesends itself the activation signal (any GC thread suspension / hijack).
Why
On the macOS version below,
execveresets a caught signal toSIG_DFLbut keeps theSA_SIGINFObit.In the new image,
sigaction(SIGUSR1, NULL, &old)reports{sa_handler = 0, sa_flags = SA_SIGINFO|SA_RESTART}(0x42).The runtime saves that as the previous action at startup.
ActivationHandlerthen chains to the previousaction; because
SA_SIGINFOis set it tail-callssa_sigaction(== 0) without first checking forSIG_DFL/SIG_IGN. Result: pc=0, lr=_sigtramp+56, x0=30 (SIGUSR1), x8=0x42 (the flags byte just loaded).The same pattern probably applies to other chained signals (SIGSEGV chaining).
Environment
microsoft.netcore.app.runtime.nativeaot.osx-arm6410.0.12Repro
probe.c(below) prints sigaction state for SIGUSR1/SIGSEGV.cc probe.c -o probesig 30 handler=0x0 flags=0x0execer2.c(below) installs an SA_SIGINFO|SA_RESTART handler for SIGUSR1, then execve()s its argument.cc execer2.c -o execer2./execer2 1 ./probe->sig 30 handler=0x0 flags=0x42(plain parent./execer2 0 ./probe-> flags=0x0)idle) withdotnet publish -c Release -r osx-arm64 -o out, then:./execer2 0 ...the process survives. Observed 3/3 vs 0/3. Without the external kill it alsocrashes on its own once a GC suspension occurs.
Expected
The runtime should not call a NULL handler: check
sa_sigaction/sa_handlerforSIG_DFL/SIG_IGNbefore chaining, regardless of
SA_SIGINFO.Notes
kernel resets the dispositions and flags.
Sources
probe.c
execer2.c
sigtest.csproj
Program.cs