Linux: Process reports its ptrace-stopped child as exited when the parent is the tracer
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
On Linux, `Process.HasExited` and `Process.WaitForExit(0)` report that a child has exited while it is alive in a ptrace stop. **The .NET parent process is itself the ptrace tracer of its `Process.Start` child.** The independently authored reproduction below uses `Process.Start` and `PTRACE_ATTACH`, but makes **no application `waitpid`/`waitid` call before observing the incorrect managed result**. It does not use ClrMD.
The use case is inspecting a child process from its parent (originally through ClrMD). This report concerns incorrect exit classification in `System.Diagnostics.Process`, not a claim that arbitrary competing native reapers must be supported. No native code reaps the child's termination before the observation. Please flag if attaching to a `Process.Start` child is itself an intentionally unsupported interaction; I did not find that restriction in the inspected implementation/API documentation.
### Reproduction Steps
On Linux with a .NET 10 SDK, create a console application in an otherwise empty directory:
```sh
dotnet new console --framework net10.0 --name PtraceExitRepro
cd PtraceExitRepro
# Replace Program.cs with the code below.
dotnet build -c Release
dotnet bin/Release/net10.0/PtraceExitRepro.dll
```
The only target is this application's own 30-second `/bin/sleep` child. No root privileges or ptrace-policy changes were needed on the tested host (`ptrace_scope=1`). The 500 ms delay deliberately exposes the reaper-winning schedule; it is not a measurement of natural race frequency. Cleanup detaches, kills and reaps only this child.
```csharp
using System.Diagnostics;
using System.Runtime.InteropServices;
using var child = Process.Start(new ProcessStartInfo("/bin/sleep")
{
ArgumentList = { "30" },
UseShellExecute = false
})!;
int pid = child.Id;
bool attached = false;
try
{
Console.WriteLine($"Before: HasExited={child.HasExited}");
nint result = Native.ptrace(16, pid, 0, 0); // PTRACE_ATTACH
if (result != 0)
throw new InvalidOperationException($"attach errno={Marshal.GetLastPInvokeError()}");
attached = true;
Thread.Sleep(500);
foreach (string line in File.ReadLines($"/proc/{pid}/status"))
if (line.StartsWith("State:") || line.StartsWith("TracerPid:"))
Console.WriteLine(line);
Console.WriteLine($"After: HasExited={child.HasExited}; WaitForExit(0)={child.WaitForExit(0)}");
// No application waitpid/waitid calls have occurred.
}
finally
{
if (attached)
Console.WriteLine($"Detach={Native.ptrace(17, pid, 0, 0)}"); // PTRACE_DETACH
bool reportedExited = child.HasExited;
Console.WriteLine($"Kill={Native.kill(pid, 9)}");
if (!reportedExited)
Console.WriteLine($"Managed cleanup={child.WaitForExit(5000)}");
else
{
// Only after recording the failure: clean up without trusting the
// already-completed managed wait. All native waits are nonblocking.
var timer = Stopwatch.StartNew();
while (timer.ElapsedMilliseconds < 5000)
{
int result = Native.waitpid(pid, out int status, 1); // WNOHANG
if (result == pid && (status & 0x7f) != 0x7f)
break;
if (result < 0 && Marshal.GetLastPInvokeError() != 4) // EINTR
break;
Thread.Sleep(10);
}
Console.WriteLine($"Child still in proc={Directory.Exists($"/proc/{pid}")}");
}
}
static class Native
{
[DllImport("libc", SetLastError = true)]
internal static extern nint ptrace(int request, int pid, nint address, nint data);
[DllImport("libc", SetLastError = true)]
internal static extern int kill(int pid, int signal);
[DllImport("libc", SetLastError = true)]
internal static extern int waitpid(int pid, out int status, int options);
}
```
### Expected behavior
While the child is alive in a tracing stop, `HasExited` and `WaitForExit(0)` remain false. A stop notification must not be classified as process termination.
### Actual behavior
```text
Before: HasExited=False
State: t (tracing stop)
TracerPid:
After: HasExited=True; WaitForExit(0)=True
Detach=0
Kill=0
Child still in proc=False
```
Fixed independent runs (not retry-until-success):
| Runtime | Attach + 500 ms pause | Same, no pre-attach HasExited call | No ptrace control |
|---|---|---|---|
| 10.0.12 | false exit 3/3 | false exit 3/3 | false exit 0/3 |
| 10.0.5 | false exit 3/3 | false exit 3/3 | false exit 0/3 |
Every run completed cleanup and exited 0. The console program records the symptom; its exit code is not a test assertion. The control modes only omit the indicated operation.
### Regression?
Also reproduced on installed 10.0.5. Earliest affected release is unknown. 10.0.12 was the newest installed patch; no preview runtime was installed or tested.
### Known Workarounds
No general workaround established. A native-owned child not registered by `Process.Start` is a control, not a proposed replacement for managed process lifecycle handling.
### Configuration
* Ubuntu 24.04, Linux x64, WSL2 kernel `6.18.33.2-microsoft-standard-WSL2`.
* glibc `2.39-0ubuntu8.9`.
* SDK selected for the independent builds: `10.0.401`.
* `System.Diagnostics.Process` informational version: `10.0.12+95017c711e6afc1085133d440e42b4bd78155701`; older comparison: `10.0.5+a612c2a1056fe3265387ae3ff7c94eba1505caf9`.
* Exact installed runtime selected for comparison with `dotnet --fx-version 10.0.5 `; no dependency packages in the minimal reproduction.
### Other information
Independent native-only controls clarify the mechanism:
1. With an attached child, both libc `waitid(P_ALL, 0, ..., WEXITED | WNOHANG | WNOWAIT)` and direct `syscall(SYS_waitid, ...)` return `si_code=CLD_TRAPPED` (4), `si_status=SIGSTOP` (19). **WNOWAIT does not consume the notification.** A subsequent ordinary `waitpid(WNOHANG)` returns `0x137f`, with `WIFSTOPPED` true. Observed 3/3 native-only trials; no managed runtime or reaper involved.
2. In another 3/3 native-only trials, calling the installed 10.0.12 `libSystem.Native.so` export `SystemNative_WaitPidExitedNoHang` instead returns the child PID but leaves a preinitialized exit-code sentinel unchanged. A subsequent `waitpid(WNOHANG)` returns 0: the stop notification was consumed by the PAL call, not an exit.
3. This agrees with Linux's deliberate ptrace semantics: [`wait_task_stopped`](https://github.com/torvalds/linux/blob/v6.18/kernel/exit.c#L1318-L1330) considers traced stops regardless of wait options. This is not evidence of a libc/kernel defect.
A separate, instrumented execution of the managed repro also observed the actual libc-call order (a local `LD_PRELOAD` wrapper delegates to libc and preserves errno; the uninstrumented matrix above does not depend on it):
```text
reaper thread: waitid(P_ALL, 0, ..., WEXITED|WNOHANG|WNOWAIT)
-> child PID, si_code=4, si_status=19
same thread: waitpid(child, ..., WNOHANG) -> child PID, status=0x137f
main thread: /proc child State=t; HasExited=True; WaitForExit(0)=True
```
The reaper and attaching thread were distinct threads of the same process. This is an observation of the minimal repro, not a trace of the historical ClrMD hang.
Exact shipped source resolves through the VMR commit above (also byte-identical for these two files to the runtime v10.0.12 tag):
* [`SystemNative_WaitIdAnyExitedNoHangNoWait` / `SystemNative_WaitPidExitedNoHang`](https://github.com/dotnet/dotnet/blob/95017c711e6afc1085133d440e42b4bd78155701/src/runtime/src/native/libs/System.Native/pal_process.c#L699-L747): the first returns the PID without checking `si_code`; the second handles only `WIFEXITED`/`WIFSIGNALED`, otherwise `assert(false)`, then returns the positive PID in release builds.
* [`ProcessWaitState.TryReapChild` / `CheckChildren`](https://github.com/dotnet/dotnet/blob/95017c711e6afc1085133d440e42b4bd78155701/src/runtime/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessWaitState.Unix.cs#L560-L630): a positive matching PID becomes `ChildReaped`. The installed managed assembly was independently inspected and has the same classification.
* Current [`runtime main` PAL](https://github.com/dotnet/runtime/blob/e2228447a138b72b6a21ebf70bcef215a10afb08/src/native/libs/System.Native/pal_process.c#L1180-L1238) still has these relevant conditions. This is source inspection, not an assertion that a main/preview build was reproduced.
**Downstream impact and limits:** [pedrosakuma/dotnet-diagnostics#879](https://github.com/pedrosakuma/dotnet-diagnostics/issues/879#issuecomment-5639173547) has a separately captured hosted .NET 10.0.12 testhost blocked in `LinuxLiveDataReader.LoadThreadsAndAttach` → `waitpid(child, NULL, 0)`, while `/proc` shows the child alive in tracing stop and the host's `ProcessWaitState._exited` is true. That is consistent with this lost-stop mechanism, but it is not a syscall trace of the original reaper. Independent unmodified ClrMD `4.1.745802` attach trials in this validation did **not** reproduce a hang: 0/20 with `Process.Start` .NET targets and 0/20 with native-spawned .NET targets. The deterministic no-ClrMD false-exit reproduction above is the basis for this report. No separate ClrMD issue is being filed for the same mechanism.
Related but distinct: #116385 and the discussion in #70705 concern another component reaping a child's **termination** and causing `ECHILD`. Here no application wait precedes the false-exit observation. This report is also distinct from the old DAC-unload/native-crash issue #128525.
Contributor guide
Research direction
Run the minimal C# ptrace reproduction first and confirm the tracing-stop state alongside the managed results. Then read pal_process.c around SystemNative_WaitIdAnyExitedNoHang/SystemNative_WaitPidExitedNoHang and ProcessWaitState.Unix.cs around TryReapChild and CheckChildren; done means a live ptrace-stopped child is not reported as exited by HasExited or WaitForExit(0).
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp, linux
- Domain
- operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100