diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs index 8869950c3a44aa..27286d1a47cb70 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs @@ -1072,5 +1072,54 @@ private static void SendSignal(PosixSignal signal, int processId) } private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal) { } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [SkipOnPlatform(TestPlatforms.Windows, "SIGCONT is not supported on Windows.")] + public void ChildProcess_WithParentSignalHandler_CanReceiveSignals() + { + // This test verifies that a child process started from a parent that has + // registered signal handlers can still receive signals correctly. + // This exercises the posix_spawn path on macOS where the child must + // cooperate correctly with signal handling in both the parent and child. + const string SignalReceivedMessage = "Signal received"; + + using RemoteInvokeHandle remoteHandle = RemoteExecutor.Invoke(() => + { + // Register a signal handler in the parent process to modify signal state + using PosixSignalRegistration parentHandler = PosixSignalRegistration.Create(PosixSignal.SIGCONT, (ctx) => + { + ctx.Cancel = true; + }); + + // Now start a child process from this parent (which has signal handlers registered) + // and verify the child can receive signals properly + const string ChildReadyMessage = "Child ready"; + + var childOptions = new RemoteInvokeOptions { CheckExitCode = false }; + childOptions.StartInfo.RedirectStandardOutput = true; + + using RemoteInvokeHandle childHandle = RemoteExecutor.Invoke(() => + { + using ManualResetEvent signalEvent = new ManualResetEvent(false); + using PosixSignalRegistration childHandler = PosixSignalRegistration.Create(PosixSignal.SIGCONT, (ctx) => + { + Console.WriteLine(SignalReceivedMessage); + signalEvent.Set(); + ctx.Cancel = true; + }); + + Console.WriteLine(ChildReadyMessage); + Assert.True(signalEvent.WaitOne(WaitInMS)); + }, childOptions); + + AssertRemoteProcessStandardOutputLine(childHandle, ChildReadyMessage, WaitInMS); + + // Send SIGCONT to the child process + SendSignal(PosixSignal.SIGCONT, childHandle.Process.Id); + + Assert.True(childHandle.Process.WaitForExit(WaitInMS)); + Assert.Equal(RemotelyInvokable.SuccessExitCode, childHandle.Process.ExitCode); + }); + } } } diff --git a/src/native/libs/System.Native/pal_process.c b/src/native/libs/System.Native/pal_process.c index 0e698293aadbbb..15909454fc4082 100644 --- a/src/native/libs/System.Native/pal_process.c +++ b/src/native/libs/System.Native/pal_process.c @@ -31,6 +31,7 @@ #ifdef __APPLE__ #include +#include #endif #ifdef __FreeBSD__ @@ -219,6 +220,116 @@ int32_t SystemNative_ForkAndExecProcess(const char* filename, int32_t stdoutFd, int32_t stderrFd) { +#if HAVE_FORK || defined(TARGET_OSX) + assert(NULL != filename && NULL != argv && NULL != envp && NULL != childPid && + (groupsLength == 0 || groups != NULL) && "null argument."); + + *childPid = -1; + + // Make sure we can find and access the executable. exec will do this, of course, but at that point it's already + // in the child process, at which point it'll translate to the child process' exit code rather than to failing + // the Start itself. There's a race condition here, in that this could change prior to exec's checks, but there's + // little we can do about that. There are also more rigorous checks exec does, such as validating the executable + // format of the target; such errors will emerge via the child process' exit code. + if (access(filename, X_OK) != 0) + { + return -1; + } +#endif + +#if defined(TARGET_OSX) + // Use posix_spawn on macOS when credentials don't need to be set, + // since macOS does not support setuid/setgid with posix_spawn. + if (!setCredentials) + { + pid_t spawnedPid; + posix_spawn_file_actions_t file_actions; + posix_spawnattr_t attr; + int result; + + if ((result = posix_spawnattr_init(&attr)) != 0) + { + errno = result; + return -1; + } + + // Build sigdefault set: only reset signals that have custom handlers, + // preserving SIG_IGN and SIG_DFL handlers (matching fork path behavior). + sigset_t sigdefault_set; + sigemptyset(&sigdefault_set); + for (int sig = 1; sig < NSIG; ++sig) + { + if (sig == SIGKILL || sig == SIGSTOP) + { + continue; + } + + struct sigaction sa_old; + if (!sigaction(sig, NULL, &sa_old)) + { + void (*oldhandler)(int) = handler_from_sigaction(&sa_old); + if (oldhandler != SIG_IGN && oldhandler != SIG_DFL) + { + sigaddset(&sigdefault_set, sig); + } + } + } + + // pthread_sigmask follows POSIX thread conventions: it returns an error number but does not set errno + sigset_t current_mask; + result = pthread_sigmask(SIG_SETMASK, NULL, ¤t_mask); + if (result != 0) + { + posix_spawnattr_destroy(&attr); + errno = result; + return -1; + } + + // POSIX_SPAWN_SETSIGDEF to reset signal handlers to default + // POSIX_SPAWN_SETSIGMASK to set the child's signal mask + short flags = POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK; + + if ((result = posix_spawnattr_setflags(&attr, flags)) != 0 + || (result = posix_spawnattr_setsigdefault(&attr, &sigdefault_set)) != 0 + || (result = posix_spawnattr_setsigmask(&attr, ¤t_mask)) != 0 // Set the child's signal mask to match the parent's current mask + || (result = posix_spawn_file_actions_init(&file_actions)) != 0) + { + int saved_errno = result; + posix_spawnattr_destroy(&attr); + errno = saved_errno; + return -1; + } + + // Redirect stdin/stdout/stderr + if ((stdinFd != -1 && (result = posix_spawn_file_actions_adddup2(&file_actions, stdinFd, STDIN_FILENO)) != 0) + || (stdoutFd != -1 && (result = posix_spawn_file_actions_adddup2(&file_actions, stdoutFd, STDOUT_FILENO)) != 0) + || (stderrFd != -1 && (result = posix_spawn_file_actions_adddup2(&file_actions, stderrFd, STDERR_FILENO)) != 0) + || (cwd != NULL && (result = posix_spawn_file_actions_addchdir_np(&file_actions, cwd)) != 0)) // Change working directory if specified + { + int saved_errno = result; + posix_spawn_file_actions_destroy(&file_actions); + posix_spawnattr_destroy(&attr); + errno = saved_errno; + return -1; + } + + // Spawn the process + result = posix_spawn(&spawnedPid, filename, &file_actions, &attr, argv, envp); + + posix_spawn_file_actions_destroy(&file_actions); + posix_spawnattr_destroy(&attr); + + if (result != 0) + { + errno = result; + return -1; + } + + *childPid = spawnedPid; + return 0; + } +#endif + #if HAVE_FORK bool success = true; int waitForChildToExecPipe[2] = {-1, -1}; @@ -234,9 +345,6 @@ int32_t SystemNative_ForkAndExecProcess(const char* filename, pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &thread_cancel_state); #endif - assert(NULL != filename && NULL != argv && NULL != envp && NULL != childPid && - (groupsLength == 0 || groups != NULL) && "null argument."); - if (setCredentials && groupsLength > 0) { getGroupsBuffer = (uint32_t*)(malloc(sizeof(uint32_t) * Int32ToSizeT(groupsLength))); @@ -247,17 +355,6 @@ int32_t SystemNative_ForkAndExecProcess(const char* filename, } } - // Make sure we can find and access the executable. exec will do this, of course, but at that point it's already - // in the child process, at which point it'll translate to the child process' exit code rather than to failing - // the Start itself. There's a race condition here, in that this could change prior to exec's checks, but there's - // little we can do about that. There are also more rigorous checks exec does, such as validating the executable - // format of the target; such errors will emerge via the child process' exit code. - if (access(filename, X_OK) != 0) - { - success = false; - goto done; - } - // We create a pipe purely for the benefit of knowing when the child process has called exec. // We can use that to block waiting on the pipe to be closed, which lets us block the parent // from returning until the child process is actually transitioned to the target program. This