Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes#124979

Closed
Copilot wants to merge 4 commits intocopilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

Implement SafeProcessHandle APIs for Linux and other Unixes#124979
Copilot wants to merge 4 commits intocopilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 27, 2026

  • Update src/native/libs/configure.cmake with new feature detection checks for Linux (clone3, pidfd_send_signal, close_range, pdeathsig, sys_tgkill)
  • Update src/native/libs/Common/pal_config.h.in with new #cmakedefine01 entries for the new features
  • Update src/native/libs/System.Native/pal_process.c:
    • Add necessary includes for sys/syscall.h, linux/sched.h, sys/prctl.h
    • Define HAVE_PIDFD when HAVE_CLONE3 is available
    • Replace the #else (ENOTSUP) branch in SystemNative_SpawnProcess with fork/exec path using clone3/fork/vfork
    • Update SystemNative_SendSignal to use pidfd_send_signal when available
    • Add pidfd-based map_wait_status_pidfd using siginfo_t for pidfd path
    • Update SystemNative_TryGetExitCode to use waitid(P_PIDFD) when pidfd available
    • Update SystemNative_WaitForExitAndReap to use waitid(P_PIDFD) when pidfd available
    • Update SystemNative_TryWaitForExitCancellable to use poll with pidfd when available
    • Update SystemNative_TryWaitForExit to use poll with pidfd when available
    • Update SystemNative_OpenProcess to use waitid verification and pidfd_open when available
  • Update test files:
    • Change SafeProcessHandleTests.Unix.cs from [PlatformSpecific(TestPlatforms.OSX)] to [PlatformSpecific(TestPlatforms.AnyUnix)]
    • Change SafeProcessHandleTests.cs from [PlatformSpecific(TestPlatforms.OSX | TestPlatforms.Windows)] to [PlatformSpecific(TestPlatforms.AnyUnix | TestPlatforms.Windows)]
  • Native code compiles successfully
  • All 395 tests pass (0 failures), including all 23 SafeProcessHandle tests
  • Address review feedback: consolidate sys/syscall.h includes

💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/area-system-io
See info in area-owners.md if you want to be subscribed.

Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copy link
Copy Markdown
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

@copilot So far I've found only one nit.

Comment thread src/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
Copilot AI changed the title [WIP] Implement SafeProcessHandle APIs for other Unixes Implement SafeProcessHandle APIs for Linux and other Unixes Feb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds
Copy link
Copy Markdown
Member

tmds commented Mar 4, 2026

We configure the terminal for Console. For child processes, this caused issues in the past because they assume the terminal to be in the "default" state (for example: echoing).

Process class (on Unix) calls ConfigureTerminalForChildProcesses to give child processes a terminal in the "default" state, and when there are no more children that use the terminal, the function is called to set the terminal back to the "Console" state.

SafeProcessHandle isn't doing this yet.

For more info, see dotnet/corefx#35621.

@tmds
Copy link
Copy Markdown
Member

tmds commented Mar 4, 2026

Some other Process behavior to be aware of:

  • When Process.Unix gets SIGCHLD but doesn't know the child it does this:

else
{
// unlikely: This is not a managed Process, so we are not responsible for reaping.
// Fall back to checking all Processes.
checkAll = true;
break;
}

Exit of SafeProcessHandle managed children will trigger this behavior.

  • Different Process instances for the same child process share information of the exit code. SafeProcessHandle doesn't implement this. I assume this is intentional for performance reasons.

@tmds
Copy link
Copy Markdown
Member

tmds commented Mar 4, 2026

The SafeProcessHandle itself doesn't ensure kernel resources are released.

Consider:

using handle = SafeChildProcessHandle.Start(...);
...
if (!handle.TryWaitForExit(TimeSpan.FromSeconds(1), out _)
   handle.Kill();

If we're in the case where the child process is killed nothing calls waitpid on the killed child. Its kernel resources won't be returned until the .NET process itself terminates.

We added the SIGCHLD handling to deal with this issue for Process (dotnet/corefx#26291).

@adamsitnik
Copy link
Copy Markdown
Member

We configure the terminal for Console. For child processes, this caused issues in the past because they assume the terminal to be in the "default" state (for example: echoing).

Thanks for sharing that, I was unaware of it.

  • Different Process instances for the same child process share information of the exit code. SafeProcessHandle doesn't implement this. I assume this is intentional for performance reasons.

It's intentional, I want to keep it as simple as possible. And since it's a new API, I can just document it. The problem is that Process itself exposes SafeProcessHandle and we can't stop people from doing:

Process process = Process.Start()
process.SafeProcessHandle.UseNewApi();

That is why I wanted to introduce a new SafeChildProcessHandle type. However, I see the benefits of the above, for example using the Signal API without the need to move it to Process:

process.SafeProcessHandle.Signal(PosixSignal.SIGKILL);

I will need to somehow integrate both Process and SafeProcessHandle because of that. My current best idea is to introduce static ConcurrentDictionary<int, ProcessExitStatus>, but I need to wrap my head around it.

FWIW my plan is to get Windows impl merged first, then macOS and then this one (this PR is a very dirty draft as of now)

@tmds
Copy link
Copy Markdown
Member

tmds commented Mar 6, 2026

I will need to somehow integrate both Process and SafeProcessHandle because of that. My current best idea is to introduce static ConcurrentDictionary<int, ProcessExitStatus>, but I need to wrap my head around it.

s_childProcessWaitStates may be what you are looking for.

my plan is to get Windows impl merged first, then macOS and then this one

Then we should address this feedback in the macOS PR because it also applies there.

Did you see #124979 (comment)? I'm asking since you haven't commented on it.

{
#if HAVE_PDEATHSIG
// On systems with PR_SET_PDEATHSIG (Linux), use it to set up parent death signal
if (prctl(PR_SET_PDEATHSIG, SIGTERM) == -1)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The "parent" in this case is considered to be the thread that
created this process. In other words, the signal will be sent
when that thread terminates (via, for example, pthread_exit(3)),
rather than after all of the threads in the parent process
terminate.

This sounds like the child process will be terminated when the .NET thread that started it exists rather than when the .NET parent process exits?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My understanding is that this logic is executed after fork, in the child process. So I would expect it to be executed by the main thread of the new child process?

Is that correct @tmds?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't think it is the main thread of the child process because then "In other words, the signal will be sent when that thread terminates" makes no sense.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it makes sense when called in other scenarios.

I will try to test it and get back to you with my findings.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <signal.h>
#include <sys/prctl.h>
#include <sys/wait.h>

static void *worker_thread(void *arg)
{
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return NULL;
    }

    if (pid == 0) {
        /* Child process */
        prctl(PR_SET_PDEATHSIG, SIGKILL);

        printf("[child %d] set PR_SET_PDEATHSIG to SIGKILL\n", getpid());
        printf("[child %d] parent process is %d\n", getpid(), getppid());
        printf("[child %d] waiting... (expect to be killed when creating thread exits)\n", getpid());

        /* Sleep long enough to observe the behavior */
        for (int i = 1; i <= 10; i++) {
            sleep(1);
            printf("[child %d] still alive after %d seconds (ppid=%d)\n", getpid(), i, getppid());
        }

        printf("[child %d] survived! (should not reach here in the gotcha case)\n", getpid());
        _exit(0);
    }

    /* Back in the worker thread of the parent process */
    printf("[thread] forked child %d, now exiting thread (but NOT the process)\n", pid);

    /* Ensure the child has time to call prctl(PR_SET_PDEATHSIG) */
    sleep(1);

    return NULL;
}

int main(void)
{
    printf("[main] pid=%d\n", getpid());

    pthread_t tid;
    if (pthread_create(&tid, NULL, worker_thread, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }

    /* Wait for the thread to finish (this causes it to be joined/terminated) */
    pthread_join(tid, NULL);
    printf("[main] worker thread has exited, but parent process is still alive\n");
    printf("[main] waiting for child...\n");

    int status;
    pid_t w = wait(&status);
    if (w > 0) {
        if (WIFSIGNALED(status))
            printf("[main] child %d was killed by signal %d (%s) — no exit code\n", w, WTERMSIG(status), WTERMSIG(status) == SIGKILL ? "SIGKILL" : "other");
        else if (WIFEXITED(status))
            printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
    }

    printf("[main] parent process exiting now\n");
    return 0;
}

The above program waits for the child to exit. PR_SET_PDEATHSIG causes the child to get killed when the thread that started it exits:

[main] pid=102816
[thread] forked child 102818, now exiting thread (but NOT the process)
[child 102818] set PR_SET_PDEATHSIG to SIGKILL
[child 102818] parent process is 102816
[child 102818] waiting... (expect to be killed when creating thread exits)
[child 102818] still alive after 1 seconds (ppid=102816)
[main] worker thread has exited, but parent process is still alive
[main] waiting for child...
[main] child 102818 was killed by signal 9 (SIGKILL) — no exit code
[main] parent process exiting now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@tmds Big thanks for providing a repro! It's true for fork, but we prefer vfork:

And when I change your sample to use vfork:

[main] pid=505
[child 507] set PR_SET_PDEATHSIG to SIGKILL
[child 507] parent process is 505
[child 507] waiting... (expect to be killed when creating thread exits)
[child 507] still alive after 1 seconds (ppid=505)
[child 507] still alive after 2 seconds (ppid=505)
[child 507] still alive after 3 seconds (ppid=505)
[child 507] still alive after 4 seconds (ppid=505)
[child 507] still alive after 5 seconds (ppid=505)
[child 507] still alive after 6 seconds (ppid=505)
[child 507] still alive after 7 seconds (ppid=505)
[child 507] still alive after 8 seconds (ppid=505)
[child 507] still alive after 9 seconds (ppid=505)
[child 507] still alive after 10 seconds (ppid=505)
[child 507] survived! (should not reach here in the gotcha case)
[thread] forked child 507, now exiting thread (but NOT the process)
[main] worker thread has exited, but parent process is still alive
[main] waiting for child...
[main] child 507 exited normally with exit code 0
[main] parent process exiting now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you share your code?

Adjusting the code to use vfork and execve (to match .NET implementation):

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <signal.h>
#include <sys/prctl.h>
#include <sys/wait.h>

static void *worker_thread(void *arg)
{
    pid_t pid = vfork();
    if (pid < 0) {
        perror("vfork");
        _exit(1);
    }

    if (pid == 0) {
        /* Child process — set pdeathsig then exec */
        prctl(PR_SET_PDEATHSIG, SIGKILL);

        char msg[128];
        int n = snprintf(msg, sizeof(msg),
            "[child %d] set PR_SET_PDEATHSIG to SIGKILL, execing sleep 10...\n",
            getpid());
        write(STDOUT_FILENO, msg, n);

        char *argv[] = {"sleep", "10", NULL};
        char *envp[] = {NULL};
        execve("/usr/bin/sleep", argv, envp);
        perror("execve");
        _exit(1);
    }

    /* Back in the worker thread of the parent process */
    printf("[thread] forked child %d, now exiting thread (but NOT the process)\n", pid);

    /* Give the child time to exec */
    sleep(1);

    return NULL;
}

int main(void)
{
    printf("[main] pid=%d\n", getpid());

    pthread_t tid;
    if (pthread_create(&tid, NULL, worker_thread, NULL) != 0) {
        perror("pthread_create");
        return 1;
    }

    pthread_join(tid, NULL);
    printf("[main] worker thread has exited, but parent process is still alive\n");
    printf("[main] waiting for child...\n");

    int status;
    pid_t w = wait(&status);
    if (w > 0) {
        if (WIFSIGNALED(status))
            printf("[main] child %d was killed by signal %d (%s)\n", w, WTERMSIG(status),
                WTERMSIG(status) == SIGKILL ? "SIGKILL" : "other");
        else if (WIFEXITED(status))
            printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
    }

    printf("[main] parent process exiting now\n");
    return 0;
}

Gives the same result for me:

 ./a.out 
[main] pid=18488
[child 18490] set PR_SET_PDEATHSIG to SIGKILL, execing sleep 10...
[thread] forked child 18490, now exiting thread (but NOT the process)
[main] worker thread has exited, but parent process is still alive
[main] waiting for child...
[main] child 18490 was killed by signal 9 (SIGKILL)
[main] parent process exiting now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but we prefer vfork:

I think you didn't call execve?

from vfork man page:

vfork() differs from fork(2) in that the calling thread is
suspended until the child terminates (either normally, by calling
_exit(2), or abnormally, after delivery of a fatal signal), or it
makes a call to execve(2).

This means your calling thread couldn't exit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think you didn't call execve?

I did not, just changed fork to vfork.

So basically to get this to work we would need to have a dedicated thread that would be kept alive for the whole time the application runs?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes, PR_SET_PDEATHSIG signals when the thread that called fork exits.

@adamsitnik
Copy link
Copy Markdown
Member

Closing due the removal of ProcessStartOptions

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants