Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Implement SafeProcessHandle APIs for Linux and other Unixes - #124979

Closed
adamsitnik with Copilot wants to merge 4 commits into
copilot/implement-safeprocesshandle-apisfrom
copilot/implement-safeprocesshandle-apis-again
Closed

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

Conversation

CopilotAI commented Feb 27, 2026

Copy link
Copy Markdown
Contributor
  • 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>

@adamsitnikadamsitnik left a comment

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.

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

Comment threadsrc/native/libs/System.Native/pal_process.c Outdated
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
CopilotAI changed the title [WIP] Implement SafeProcessHandle APIs for other UnixesImplement SafeProcessHandle APIs for Linux and other UnixesFeb 27, 2026
Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com>
@tmds

tmds commented Mar 4, 2026

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).

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

tmds commented Mar 4, 2026

Copy link
Copy Markdown
Member

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

Consider:

usinghandle=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:

Processprocess=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

tmds commented Mar 6, 2026

Copy link
Copy Markdown
Member

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=fork();
if (pid<0) {
perror("fork");
returnNULL;
}
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 (inti=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
/* 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");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with exit code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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>staticvoid*worker_thread(void*arg)
{
pid_tpid=vfork();
if (pid<0) {
perror("vfork");
_exit(1);
}
if (pid==0) {
/* Child process — set pdeathsig then exec */prctl(PR_SET_PDEATHSIG, SIGKILL);
charmsg[128];
intn=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);
returnNULL;
}
intmain(void)
{
printf("[main] pid=%d\n", getpid());
pthread_ttid;
if (pthread_create(&tid, NULL, worker_thread, NULL) !=0) {
perror("pthread_create");
return1;
}
pthread_join(tid, NULL);
printf("[main] worker thread has exited, but parent process is still alive\n");
printf("[main] waiting for child...\n");
intstatus;
pid_tw=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");
elseif (WIFEXITED(status))
printf("[main] child %d exited normally with code %d\n", w, WEXITSTATUS(status));
}
printf("[main] parent process exiting now\n");
return0;
}

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

@jkotas
jkotas deleted the copilot/implement-safeprocesshandle-apis-again branch May 22, 2026 15:57
Sign up for freeto 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

@tmds@adamsitnik