Fix ptrace stop misclassification and ownership-gated draining in CheckChildren - #133948
adamsitnik wants to merge 4 commits into
Conversation
On macOS, waitid(P_ALL, WEXITED | WNOHANG | WNOWAIT) also reports children that have stopped (SIGSTOP) or continued (SIGCONT), not just exited ones. Because WNOWAIT does not consume the notification, SystemNative_WaitIdAnyExitedNoHangNoWait kept returning the same stopped PID, causing the SIGCHLD reaper (CheckChildren) to spin forever while holding s_childProcessWaitStates / s_processStartLock. Process.Kill(entireProcessTree: true) SIGSTOPs the whole tree before killing it (the two-phase StopTree added in dotnet#128598), so a concurrent kill would leave a direct child stopped long enough for the reaper to wedge on it, deadlocking every concurrent Kill/Start. This is why the SDK dotnet-watch tests hung on osx.15.arm64. Only report children whose si_code indicates an actual exit (CLD_EXITED / CLD_KILLED / CLD_DUMPED). When a stopped/continued child is reported, consume that notification and keep looking for a real exit, which also unmasks any exited child that macOS was hiding behind the stopped one. On platforms that honor WEXITED (e.g. Linux) the new branch is never taken. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Builds on the macOS deadlock fix from dotnet#131944/dotnet#133930 (cherry-picked as the prior commit) and addresses a reviewer concern on that PR (https://github.com/dotnet/runtime/pull/133930/changes#r4013927001): draining a non-exit wait notification for a pid Process doesn't own could interfere with unrelated native code (e.g. code using WUNTRACED/WCONTINUED on that pid directly). - SystemNative_WaitIdAnyExitedNoHangNoWait now only peeks and classifies the pending notification (via si_code), and never consumes/drains it itself. - New SystemNative_WaitIdDrainNonExited(pid) performs the targeted drain of a non-exit notification, to be called only after managed code confirms (via s_childProcessWaitStates) that it owns the pid. - CheckChildren now drains a non-exit notification only for pids it owns, and otherwise leaves it untouched and stops this pass (a subsequent SIGCHLD will retry), instead of retrying in a loop. - Fixes SystemNative_WaitPidExitedNoHang to not misreport a WIFSTOPPED/WIFCONTINUED status (visible to ptrace tracers even without WUNTRACED) as a normal exit with code 0, which is the root cause of dotnet#133736. - Adds a regression test for dotnet#133736 that reproduces the misclassification without the fix and passes with it. Fixes dotnet#133736 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @dotnet/area-system-diagnostics-process |
There was a problem hiding this comment.
🟡 Changes recommended
Critical ptrace ownership and WASI build issues, along with additional child-scanning and test reliability concerns, remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes Unix child-wait classification for ptraced processes and adds ownership-gated notification draining.
Changes:
- Classifies
waitidnotifications and adds targeted draining. - Updates managed child checking and
waitpidhandling. - Adds regression tests and interop updates.
File summaries
| File | Summary |
|---|---|
src/native/libs/System.Native/pal_process.h |
Updates wait APIs; WASI declarations need matching implementations. |
src/native/libs/System.Native/pal_process.c |
Implements notification classification and draining. |
src/native/libs/System.Native/entrypoints.c |
Exports the drain function; WASI binding remains unresolved. |
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs |
Adds regression coverage; test readiness, cleanup, and runtime cost need improvement. |
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessWaitState.Unix.cs |
Applies ownership-aware handling; ptrace ownership, PID reuse, and pending-exit scanning issues remain. |
src/libraries/Common/src/Interop/Unix/System.Native/Interop.WaitPid.cs |
Updates wait behavior interop documentation. |
src/libraries/Common/src/Interop/Unix/System.Native/Interop.WaitId.cs |
Adds waitid interop declarations. |
Review details
Suppressed comments (2)
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1201
- A fixed 500 ms delay does not establish that all
/bin/sleepgrandchildren have started before the roots are stopped. On a slow runner, tree enumeration can miss a grandchild, so this test may pass without exercising recursive termination and leave an untrackedsleepprocess running; use a readiness handshake or otherwise record each child before launching the kill tasks.
// Give the grandchildren time to start so the trees are fully formed.
Thread.Sleep(500);
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1181
- This is a normal test case but runs 30 iterations of 8 two-process trees, creating roughly 480 child processes and adding a 500 ms delay per iteration before the actual assertions. That is a substantial cost for every standard Process test run; the neighboring process-heavy/long-running tests use
[OuterLoop]. Mark this test as outer-loop or reduce the workload so the regression coverage does not slow the regular suite.
const int TreeCount = 8;
const int Iterations = 30;
- Files reviewed: 7/7 changed files
- Comments generated: 6
- Review effort level: Lite
Address review feedback on dotnet#133948: - Remove the WaitIdDrainNonExited native/managed mechanism entirely. Draining a non-exit notification for an "owned" pid could steal a ptrace stop (CLD_TRAPPED) notification from an external tracer (e.g. ClrMD) attached to the same pid, and pid-reuse could make the ownership check itself unreliable. Falling back to the pre-existing checkAll full scan (which reaps only via per-pid targeted waitpid) achieves the same "make progress without spinning" goal without touching/consuming a notification that might not belong to us, and also avoids stranding an already-exited sibling in the same coalesced SIGCHLD batch. - Fix SystemNative_WaitIdAnyExitedNoHangNoWait's WASI stub signature, which still used the old (void) signature and would fail to link against the shared pal_process.h declaration. - Strengthen Kill_EntireProcessTree_Concurrent_DoesNotHang to track and assert on grandchild processes exiting, not just the roots, so a regression that only kills roots would be caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved moderate findings remain in ownership-gated draining and all-child reaping.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (5)
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessWaitState.Unix.cs:639
- The PR summary says this path calls a new
SystemNative_WaitIdDrainNonExited(pid)after confirming ownership, but no such entry point is declared or implemented in this change. The actual fallback scans every tracked child and callsTryReapChild, so the implementation and the stated targeted-drain ownership design no longer match. Please update the description or narrow the implementation to the intended targeted operation so reviewers can verify the native-wait contract.
// Either this pid is not one we're responsible for reaping, or (on some
// platforms, e.g. macOS, or for a ptrace-traced child on Linux) the
// notification isn't actually an exit even though only exit notifications
// (WEXITED) were requested. In both cases we must not consume/act on this
// specific notification: it may belong to something else in this process
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1180
- This test creates 8 roots and 8 grandchildren for 30 iterations (480 process launches), but it is not marked
[OuterLoop]. That makes the normal Process test pass pay a substantial process-creation/teardown cost and increases CI/process-limit pressure; the existing expensive process-launch tests in this file are isolated similarly. Mark this regression test as outer-loop, or reduce the workload after preserving the race coverage.
[ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[PlatformSpecific(TestPlatforms.OSX)]
public void Kill_EntireProcessTree_Concurrent_DoesNotHang()
{
const int TreeCount = 8;
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1261
- This regression test never acts as a native waiter for the ptrace stop; it only detaches after the managed reaper has had a chance to run. It can therefore pass even when
CheckChildrenconsumes the stop notification, so it does not verify the ownership/interference contract described by the PR. Add an isolated nativewaitpid(..., WUNTRACED)assertion or an equivalent unmanaged-tracer scenario.
// SIGCHLD is installed with SA_NOCLDSTOP, so the ptrace stop above does not by itself
// wake up CheckChildren. Spawn unrelated short-lived children concurrently: each real
// exit delivers a SIGCHLD that runs CheckChildren, which (via waitid(P_ALL, ...)) will
// also observe -- and, without the fix, misclassify -- the stopped tracee.
src/native/libs/System.Native/pal_process.c:1258
- When
reapAllis true,CheckChildrencallsWaitPidExitedNoHang(-1)and treats a zero return as “no more children” (the caller breaks onpid <= 0). Ifwaitpid(-1, WNOHANG)selects a ptrace stop/continue before another child’s exit, this branch consumes the non-exit status and converts the result to 0, so the loop stops without examining the pending exit. SinceSIGCHLDdelivery can be coalesced, no later callback is guaranteed and a tracked child can remain unmarked as exited. Keep the all-child path able to distinguish a consumed non-exit from no waitable child and continue or scan accordingly.
else if (WIFSTOPPED(status) || WIFCONTINUED(status))
{
// The child has not exited -- it was merely stopped or continued. This can happen even
// without WUNTRACED/WCONTINUED being requested when this process is the child's ptrace
// tracer (e.g. via PTRACE_ATTACH): stop/continue transitions of a tracee are always
// visible to its tracer's wait calls. Do not misreport this as an exit.
result = 0;
}
src/native/libs/System.Native/pal_process.c:1208
- Unlike the PR summary, this diff contains no
SystemNative_WaitIdDrainNonExiteddeclaration, export, implementation, or managed call; non-exit notifications are only classified and left pending. That is a substantive design mismatch, because the macOS fix from #133930 relied on draining to expose exits hidden behind a stopped child. Either add the ownership-gated drain (with matching platform stubs/exports) or update the PR contract and demonstrate that the fallback is sufficient.
// classes. This function only peeks (WNOWAIT) and classifies the notification -- it never
// consumes it, so callers can safely fall back to checking their own known children
// directly without risking interference with an unrelated waiter for this pid.
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
| checkAll = true; | ||
| break; |
…k scans Addresses two remaining review comments on dotnet#133948: - CheckChildren's checkAll fallback now skips re-checking the specific pid that triggered it when that pid is a known child with a pending non-exit notification, since we already know from the non-consuming peek that it hasn't exited. Otherwise the full scan's TryReapChild call would invoke the consuming waitpid() on it, risking stealing a ptrace stop notification (e.g. CLD_TRAPPED) from an external tracer such as ClrMD attached to the same pid. - SystemNative_WaitPidExitedNoHang no longer returns 0 as soon as it observes a stopped/continued (non-exit) status. Since that specific transition has already been consumed/reported, it now retries immediately instead, so the reapAll wildcard (-1) loop won't prematurely stop reaping other, already-exited children coalesced into the same SIGCHLD batch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Unresolved issues remain around notification preservation, test cleanup, and 32-bit ptrace ABI coverage.
Review details
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1290
- The
ptracerequest parameter is a 4-byte enum/int in the Unix ABI, but this declaration uses an 8-byte managedlong. On 32-bit Unix, that shifts the variadicpidargument, soPTRACE_ATTACH/PTRACE_DETACHreceive the wrong pid and this regression test cannot run on supportedlinux-arm/linux-x86targets.
src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessWaitState.Unix.cs:678
- Skipping only the one PID returned by the
waitidpeek does not protect other pending non-exit notifications. If two tracked children are ptrace-stopped, the fallback reaches the second child here;TryReapChildcallswaitpid, and the new native loop consumes that child'sWIFSTOPPED/WIFCONTINUEDstatus, so an external tracer can miss it. Track all candidates known to have non-exit notifications or classify each PID without a consuming wait before reaping.
if (kv.Key == pidToSkip)
{
continue;
}
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1225
- If any assertion in this loop fails (including the 30-second grandchild wait), execution skips these
Disposecalls.ProcessTestBaseonly tracks the roots, and its cleanup kills roots withoutentireProcessTree, so the/bin/sleep 1000grandchildren can be orphaned and left running after a failed test. Put grandchild cleanup in afinally(or otherwise register a cleanup that also terminates them).
for (int i = 0; i < TreeCount; i++)
{
Assert.True(roots[i].WaitForExit(WaitInMS));
Assert.True(grandChildren[i].WaitForExit(WaitInMS), $"Grandchild {grandChildren[i].Id} was not killed on iteration {iteration}.");
grandChildren[i].Dispose();
src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs:1271
- These assertions only prove that managed state remains
false; an incorrect fallback could still consume the child'sCLD_TRAPPEDstatus viawaitpid, then return 0 and pass both assertions. Add coverage that the stop notification remains observable to the tracer (for example, a non-consumingwaitidcheck or a second traced child whose wait status is verified), since preserving that notification is a stated part of this change.
Assert.False(child.HasExited);
Assert.False(child.WaitForExit(0));
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Summary
This builds on the macOS deadlock fix from #131944 / #133930 (cherry-picked as the first commit of this PR) and addresses a reviewer concern raised on #133930 (https://github.com/dotnet/runtime/pull/133930/changes#r4013927001): a non-exit wait notification observed for a pid could belong to something else entirely (a foreign, non-
Process-owned pid, or an external tracer such as ClrMD attached to a pid this runtime also happens to be tracking), so it must never be blindly consumed.It also fixes #133736, where a
waitpid(pid, WNOHANG)(noWUNTRACED) call on a pid that this process is ptrace-tracing observes the tracee's stop/continue transitions (always visible to a tracer), which was misclassified as a normal exit with code 0.Changes
SystemNative_WaitIdAnyExitedNoHangNoWaitnow only peeks (WNOWAIT) and classifies the pending notification viasi_code(CLD_EXITED/CLD_KILLED/CLD_DUMPEDvs. others); it never consumes/drains anything.CheckChildren's peek loop only reaps a pid directly when the notification is an actual exit (isExited) and the pid is a known/owned child. In every other case — a non-exit notification, or a pid we don't recognize — it leaves the notification completely untouched and falls back tocheckAll, which performs a full scan of all known children using each child's own targeted, per-pidwaitpid()(TryReapChild). This makes progress without spinning, without ever consuming or otherwise interfering with a notification that might not belong to us (e.g. aCLD_TRAPPEDstop that an external tracer like ClrMD needs to observe), and without relying on cache membership as a proxy for pid ownership.SystemNative_WaitPidExitedNoHangto not misreport aWIFSTOPPED/WIFCONTINUEDstatus as a normal exit with code 0.SA_NOCLDSTOP, so the stop by itself does not trigger a check; unrelated child activity is needed to exercise the bug). Verified this test fails without the fix (reproducing the reportedHasExited == truemisclassification) and passes with it.Kill_EntireProcessTree_Concurrent_DoesNotHangtest to also track and assert on grandchild processes exiting, not just the roots, so a regression that kills only the roots (but not the whole tree) would be caught.An earlier iteration of this PR introduced a separate
SystemNative_WaitIdDrainNonExited(pid)entrypoint that would consume a non-exit notification once the managed layer confirmed (vias_childProcessWaitStates) that it owned the pid's reaping responsibility. That approach was replaced by thecheckAllfallback described above, because pid membership ins_childProcessWaitStatesdoesn't reliably prove current ownership after pid reuse, and because draining could still steal a notification (e.g. a ptrace stop) that an external tracer attached to the same pid needed to see.Testing
System.Diagnostics.Process.Tests(including outerloop): 689 total, 0 failed, 8 skipped.Fixes #133736
Note
This PR description and the accompanying implementation were prepared with GitHub Copilot CLI assistance.