From 75877edd5205d4344565251624374397dd9969f6 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:09:01 -0400 Subject: [PATCH 1/6] Reap the reparented probe descendant so it cannot outlive the test (#1019) --- .../Services/ProcessTree.cs | 25 +++++++++ .../DaemonRunnerVersionProbeTests.cs | 56 +++++++++++++++---- 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs index 015cfba618..cd4a738d5f 100644 --- a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs +++ b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs @@ -50,6 +50,31 @@ public static void Kill(int pid) { if (ProcessIdentity.Capture(pid) is { } identity) KillSubtree(pid, identity); } + /// Kills only while still carries . Use + /// when the caller captured the identity itself — otherwise the bare would + /// take a FRESH identity, and a pid recycled between the caller's capture and here would let it + /// signal the replacement process. A mismatch is a no-op, not a failure. + public static void Kill(int pid, string identity) { + if (pid <= 0) return; + + if (OperatingSystem.IsWindows()) { + try { + // Open FIRST, then derive the token from the SAME handle we are about to kill — + // checking a pid-based token before opening would let a recycled pid slip through. + using var process = Process.GetProcessById(pid); + // Force the Process to retain its long-term SafeHandle: StartTime and Kill each + // otherwise open their OWN short-term handle, letting the pid be reused between + // them. Reading Handle pins one handle for both. + _ = process.Handle; + if ($"tk:{process.StartTime.ToUniversalTime().Ticks}" != identity) return; + Kill(process); + } catch (ArgumentException) { /* already gone */ } + return; + } + + if (ProcessIdentity.Matches(pid, identity)) KillSubtree(pid, identity); + } + static void KillSubtree(int pid, string identity) { var seen = new HashSet(); diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs index 2fd20b62bc..633864d978 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs @@ -102,20 +102,56 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ using var tmp = new TempDir(); // Prints the version, then leaves a child holding the inherited stdout open after the CLI // itself exits — the shape that made ProbeCliVersionOnce block on a drain that never saw EOF. + // The stub records the descendant's pid so the test can reap it: once reparented it is no + // longer anyone's child, and left alone it outlives the test host into teardown. The sidecar + // path comes from the fixture so the shell writer and the reaper cannot drift apart. + var pidFile = tmp.PathTo("faketool.pid"); var cli = tmp.CreateFile( - "faketool", "#!/bin/sh\necho 'faketool 9.9.9'\nsleep 20 &\nexit 0\n"); + "faketool", $"#!/bin/sh\necho 'faketool 9.9.9'\nsleep 20 &\necho $! > '{pidFile}'\nexit 0\n"); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(cli, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); var sw = System.Diagnostics.Stopwatch.StartNew(); - var version = await Task.Run(() => DaemonRunner.ProbeCliVersionForLaunch(cli)); - sw.Stop(); - - // Bounded: a return well under the descendant's 20s lifetime proves the drain was not blocked - // to an EOF that never comes. - await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(12)); - // Recovered: the version printed ahead of the block survives, so registration advertises it - // rather than an unknown version that would reject a launch. - await Assert.That(version).IsEqualTo("9.9.9"); + string? version = null; + try { + version = await Task.Run(() => DaemonRunner.ProbeCliVersionForLaunch(cli)); + sw.Stop(); + + // Bounded: a return well under the descendant's 20s lifetime proves the drain was not + // blocked to an EOF that never comes. + await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(12)); + // Recovered: the version printed ahead of the block survives, so registration advertises + // it rather than an unknown version that would reject a launch. + await Assert.That(version).IsEqualTo("9.9.9"); + } finally { + // In a finally so a failing probe or assertion still reaps — otherwise the same leaked + // descendant turns a useful assertion failure into an exit-139 teardown failure. + await ReapDescendantAsync(pidFile); + } + } + + /// Kills the reparented descendant the stub left behind, so it cannot linger past this + /// test and into the host's teardown. Best-effort: a missing or already-gone pid is fine. + static async Task ReapDescendantAsync(string pidFile) { + for (var i = 0; i < 50 && !File.Exists(pidFile); i++) await Task.Delay(20); + if (!File.Exists(pidFile)) return; + if (!int.TryParse((await File.ReadAllTextAsync(pidFile)).Trim(), out var pid)) return; + + // Capture the incarnation. Capture throws both when the pid is gone AND when a live process's + // identity is unreadable, so a bare catch is not proof of absence: only return when the pid is + // actually absent, and otherwise surface so a live descendant is never silently skipped. + string identity; + try { identity = Capacitor.Tests.Helpers.PidIdentity.Capture(pid); } + catch { + try { + using var descendant = System.Diagnostics.Process.GetProcessById(pid); + if (descendant.HasExited) return; + } catch (ArgumentException) { return; } // genuinely gone + throw; // present but identity unreadable — do not assume it is gone + } + + // Kill against the captured incarnation, so a pid recycled since is never signalled. + Capacitor.Cli.Daemon.Services.ProcessTree.Kill(pid, identity); + await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(pid, identity, TimeSpan.FromSeconds(5)); } } From 250a71cda3df47c50ef22e2c83bbdb44f94288b3 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:50:38 -0400 Subject: [PATCH 2/6] Capture the descendant's identity while it is provably the stub's own Co-Authored-By: Claude Opus 5.5 --- .../DaemonRunnerVersionProbeTests.cs | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs index 6ce4a8ddef..c923b667fa 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs @@ -104,15 +104,18 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ // path comes from the fixture so the shell writer and the reaper cannot drift apart. var pidFile = tmp.PathTo("faketool.pid"); var cli = tmp.CreateExecutable( - "faketool", $"#!/bin/sh\necho 'faketool 9.9.9'\nsleep 20 &\necho $! > '{pidFile}'\nexit 0\n"); + "faketool", + $"#!/bin/sh\necho 'faketool 9.9.9'\nsleep {(int)DescendantLifetime.TotalSeconds} &\necho $! > '{pidFile}'\nexit 0\n"); - var sw = System.Diagnostics.Stopwatch.StartNew(); + var sinceLaunch = System.Diagnostics.Stopwatch.StartNew(); + var descendant = CaptureDescendantAsync(pidFile, sinceLaunch); + var sw = System.Diagnostics.Stopwatch.StartNew(); string? version = null; try { version = await Task.Run(() => DaemonRunner.ProbeCliVersionForLaunch(cli)); sw.Stop(); - // Bounded: a return well under the descendant's 20s lifetime proves the drain was not + // Bounded: a return well under the descendant's lifetime proves the drain was not // blocked to an EOF that never comes. await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(12)); // Recovered: the version printed ahead of the block survives, so registration advertises @@ -121,32 +124,47 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ } finally { // In a finally so a failing probe or assertion still reaps — otherwise the same leaked // descendant turns a useful assertion failure into an exit-139 teardown failure. - await ReapDescendantAsync(pidFile); + await ReapAsync(await descendant); } } - /// Kills the reparented descendant the stub left behind, so it cannot linger past this - /// test and into the host's teardown. Best-effort: a missing or already-gone pid is fine. - static async Task ReapDescendantAsync(string pidFile) { - for (var i = 0; i < 50 && !File.Exists(pidFile); i++) await Task.Delay(20); - if (!File.Exists(pidFile)) return; - if (!int.TryParse((await File.ReadAllTextAsync(pidFile)).Trim(), out var pid)) return; - - // Capture the incarnation. Capture throws both when the pid is gone AND when a live process's - // identity is unreadable, so a bare catch is not proof of absence: only return when the pid is - // actually absent, and otherwise surface so a live descendant is never silently skipped. - string identity; - try { identity = Capacitor.Tests.Helpers.PidIdentity.Capture(pid); } - catch { - try { - using var descendant = System.Diagnostics.Process.GetProcessById(pid); - if (descendant.HasExited) return; - } catch (ArgumentException) { return; } // genuinely gone - throw; // present but identity unreadable — do not assume it is gone + static readonly TimeSpan DescendantLifetime = TimeSpan.FromSeconds(20); + + /// Captures the descendant's identity while it is provably the stub's own. It starts after + /// began and lives , so an identity + /// read inside that window cannot belong to a recycled pid; one read later might, and is discarded + /// rather than risk signalling another process. + static async Task<(int Pid, string Identity)?> CaptureDescendantAsync(string pidFile, System.Diagnostics.Stopwatch sinceLaunch) { + var ownedUntil = DescendantLifetime - TimeSpan.FromSeconds(2); + + while (true) { + if (sinceLaunch.Elapsed >= ownedUntil) return null; + if (File.Exists(pidFile) && int.TryParse((await File.ReadAllTextAsync(pidFile)).Trim(), out var pid)) { + // Capture throws both when the pid is gone AND when a live process's identity is + // unreadable, so a bare catch is not proof of absence. + string identity; + try { identity = Capacitor.Tests.Helpers.PidIdentity.Capture(pid); } + catch { + try { + using var process = System.Diagnostics.Process.GetProcessById(pid); + if (process.HasExited) return null; + } catch (ArgumentException) { return null; } // genuinely gone + throw; // present but identity unreadable — do not assume it is gone + } + + return sinceLaunch.Elapsed < ownedUntil ? (pid, identity) : null; + } + + await Task.Delay(20); } + } + + /// Kills the reparented descendant against its captured incarnation, so it cannot linger + /// past this test into the host's teardown and a pid recycled since is never signalled. + static async Task ReapAsync((int Pid, string Identity)? descendant) { + if (descendant is not { } d) return; - // Kill against the captured incarnation, so a pid recycled since is never signalled. - Capacitor.Cli.Daemon.Services.ProcessTree.Kill(pid, identity); - await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(pid, identity, TimeSpan.FromSeconds(5)); + Capacitor.Cli.Daemon.Services.ProcessTree.Kill(d.Pid, d.Identity); + await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(d.Pid, d.Identity, TimeSpan.FromSeconds(5)); } } From a5da81d11eb943cbef2cbebb93b6009d837deaf2 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:54:24 -0400 Subject: [PATCH 3/6] Prove the descendant's ownership by a command-line marker, not by elapsed time Co-Authored-By: Claude Opus 5.5 --- .../DaemonRunnerVersionProbeTests.cs | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs index c923b667fa..0623ebf648 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs @@ -100,22 +100,21 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ // Prints the version, then leaves a child holding the inherited stdout open after the CLI // itself exits — the shape that made ProbeCliVersionOnce block on a drain that never saw EOF. // The stub records the descendant's pid so the test can reap it: once reparented it is no - // longer anyone's child, and left alone it outlives the test host into teardown. The sidecar - // path comes from the fixture so the shell writer and the reaper cannot drift apart. + // longer anyone's child, and left alone it outlives the test host into teardown. The marker + // in its command line is what proves a pid is still that descendant when the test reaps it. + // (`; :` keeps the shell from exec-ing sleep, which would drop the marker.) var pidFile = tmp.PathTo("faketool.pid"); + var marker = $"kcap-probe-descendant-{Guid.NewGuid():N}"; var cli = tmp.CreateExecutable( - "faketool", - $"#!/bin/sh\necho 'faketool 9.9.9'\nsleep {(int)DescendantLifetime.TotalSeconds} &\necho $! > '{pidFile}'\nexit 0\n"); + "faketool", $"#!/bin/sh\necho 'faketool 9.9.9'\nsh -c 'sleep 20; :' {marker} &\necho $! > '{pidFile}'\nexit 0\n"); - var sinceLaunch = System.Diagnostics.Stopwatch.StartNew(); - var descendant = CaptureDescendantAsync(pidFile, sinceLaunch); - var sw = System.Diagnostics.Stopwatch.StartNew(); + var sw = System.Diagnostics.Stopwatch.StartNew(); string? version = null; try { version = await Task.Run(() => DaemonRunner.ProbeCliVersionForLaunch(cli)); sw.Stop(); - // Bounded: a return well under the descendant's lifetime proves the drain was not + // Bounded: a return well under the descendant's 20s lifetime proves the drain was not // blocked to an EOF that never comes. await Assert.That(sw.Elapsed).IsLessThan(TimeSpan.FromSeconds(12)); // Recovered: the version printed ahead of the block survives, so registration advertises @@ -124,47 +123,48 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ } finally { // In a finally so a failing probe or assertion still reaps — otherwise the same leaked // descendant turns a useful assertion failure into an exit-139 teardown failure. - await ReapAsync(await descendant); + await ReapDescendantAsync(pidFile, marker); } } - static readonly TimeSpan DescendantLifetime = TimeSpan.FromSeconds(20); - - /// Captures the descendant's identity while it is provably the stub's own. It starts after - /// began and lives , so an identity - /// read inside that window cannot belong to a recycled pid; one read later might, and is discarded - /// rather than risk signalling another process. - static async Task<(int Pid, string Identity)?> CaptureDescendantAsync(string pidFile, System.Diagnostics.Stopwatch sinceLaunch) { - var ownedUntil = DescendantLifetime - TimeSpan.FromSeconds(2); - - while (true) { - if (sinceLaunch.Elapsed >= ownedUntil) return null; - if (File.Exists(pidFile) && int.TryParse((await File.ReadAllTextAsync(pidFile)).Trim(), out var pid)) { - // Capture throws both when the pid is gone AND when a live process's identity is - // unreadable, so a bare catch is not proof of absence. - string identity; - try { identity = Capacitor.Tests.Helpers.PidIdentity.Capture(pid); } - catch { - try { - using var process = System.Diagnostics.Process.GetProcessById(pid); - if (process.HasExited) return null; - } catch (ArgumentException) { return null; } // genuinely gone - throw; // present but identity unreadable — do not assume it is gone - } - - return sinceLaunch.Elapsed < ownedUntil ? (pid, identity) : null; - } - + /// Kills the reparented descendant so it cannot linger past this test into the host's + /// teardown. A pid is killed only when its captured incarnation still carries this test's marker: + /// a descendant that already exited may have had its pid reused, and that process is left alone. + static async Task ReapDescendantAsync(string pidFile, string marker) { + int pid = 0; + for (var i = 0; i < 50; i++) { + if (File.Exists(pidFile) && int.TryParse((await File.ReadAllTextAsync(pidFile)).Trim(), out pid)) break; await Task.Delay(20); } - } + if (pid <= 0) return; + + // Capture throws both when the pid is gone AND when a live process's identity is unreadable, + // so a bare catch is not proof of absence. + string identity; + try { identity = Capacitor.Tests.Helpers.PidIdentity.Capture(pid); } + catch { + try { + using var process = System.Diagnostics.Process.GetProcessById(pid); + if (process.HasExited) return; + } catch (ArgumentException) { return; } // genuinely gone + throw; // present but identity unreadable — do not assume it is gone + } - /// Kills the reparented descendant against its captured incarnation, so it cannot linger - /// past this test into the host's teardown and a pid recycled since is never signalled. - static async Task ReapAsync((int Pid, string Identity)? descendant) { - if (descendant is not { } d) return; + // The marker is read after the capture and the identity checked again after the read, so the + // command line that carried the marker belongs to the captured incarnation. + if (!(await CommandLineAsync(pid)).Contains(marker, StringComparison.Ordinal)) return; + if (Capacitor.Tests.Helpers.PidIdentity.IsGone(pid, identity)) return; + + Capacitor.Cli.Daemon.Services.ProcessTree.Kill(pid, identity); + await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(pid, identity, TimeSpan.FromSeconds(5)); + } - Capacitor.Cli.Daemon.Services.ProcessTree.Kill(d.Pid, d.Identity); - await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(d.Pid, d.Identity, TimeSpan.FromSeconds(5)); + static async Task CommandLineAsync(int pid) { + using var ps = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ps", ["-o", "args=", "-p", pid.ToString(System.Globalization.CultureInfo.InvariantCulture)]) { + RedirectStandardOutput = true, UseShellExecute = false, + })!; + var output = await ps.StandardOutput.ReadToEndAsync(); + await ps.WaitForExitAsync(); + return output; } } From b25e1ea829ea6f3c08590f8e12a3dea112238268 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 09:56:39 -0400 Subject: [PATCH 4/6] Re-check the root before acting on its listed children, and quote the stub's pid path Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Daemon/Services/ProcessTree.cs | 9 +++++++-- .../DaemonRunnerVersionProbeTests.cs | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs index cd4a738d5f..350b65eced 100644 --- a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs +++ b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs @@ -79,9 +79,14 @@ static void KillSubtree(int pid, string identity) { var seen = new HashSet(); for (var rescan = 0; rescan <= MaxRescans; rescan++) { - var fresh = false; + var fresh = false; + var children = Children(pid).ToList(); - foreach (var child in Children(pid)) { + // Act on the listed children only if the root is still the same incarnation after listing + // them: a pid reused in between would otherwise hand the replacement's children to this kill. + if (!ProcessIdentity.Matches(pid, identity)) return; + + foreach (var child in children) { if (!seen.Add(child)) continue; // handled on an earlier pass; a zombie stays listed until its parent dies fresh = true; diff --git a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs index 0623ebf648..8c5c8ae8ae 100644 --- a/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs +++ b/test/Capacitor.Cli.Daemon.Tests.Unit/DaemonRunnerVersionProbeTests.cs @@ -100,13 +100,13 @@ public async Task A_descendant_holding_the_pipe_open_is_bounded_and_still_reads_ // Prints the version, then leaves a child holding the inherited stdout open after the CLI // itself exits — the shape that made ProbeCliVersionOnce block on a drain that never saw EOF. // The stub records the descendant's pid so the test can reap it: once reparented it is no - // longer anyone's child, and left alone it outlives the test host into teardown. The marker + // longer the test's child to wait on, and left alone it outlives the test host into teardown. The marker // in its command line is what proves a pid is still that descendant when the test reaps it. // (`; :` keeps the shell from exec-ing sleep, which would drop the marker.) var pidFile = tmp.PathTo("faketool.pid"); var marker = $"kcap-probe-descendant-{Guid.NewGuid():N}"; var cli = tmp.CreateExecutable( - "faketool", $"#!/bin/sh\necho 'faketool 9.9.9'\nsh -c 'sleep 20; :' {marker} &\necho $! > '{pidFile}'\nexit 0\n"); + "faketool", $"#!/bin/sh\necho 'faketool 9.9.9'\nsh -c 'sleep 20; :' {marker} &\necho $! > {ShellQuote(pidFile)}\nexit 0\n"); var sw = System.Diagnostics.Stopwatch.StartNew(); string? version = null; @@ -159,6 +159,8 @@ static async Task ReapDescendantAsync(string pidFile, string marker) { await Capacitor.Tests.Helpers.PidIdentity.WaitUntilGoneAsync(pid, identity, TimeSpan.FromSeconds(5)); } + static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + static async Task CommandLineAsync(int pid) { using var ps = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("ps", ["-o", "args=", "-p", pid.ToString(System.Globalization.CultureInfo.InvariantCulture)]) { RedirectStandardOutput = true, UseShellExecute = false, From a4c7c6f609db9d1c853d20213b3520aa48b4fc68 Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:24:41 -0400 Subject: [PATCH 5/6] Keep killing listed children when their root merely exited Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Daemon/Services/ProcessTree.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs index 350b65eced..fc1fb5d23d 100644 --- a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs +++ b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs @@ -82,9 +82,10 @@ static void KillSubtree(int pid, string identity) { var fresh = false; var children = Children(pid).ToList(); - // Act on the listed children only if the root is still the same incarnation after listing - // them: a pid reused in between would otherwise hand the replacement's children to this kill. - if (!ProcessIdentity.Matches(pid, identity)) return; + // A root proven to be a different incarnation after listing means the pid was reused, so the + // listed children may be the replacement's and are left alone. A root that merely exited + // (identity unreadable) still owned them when they were listed, so they are killed. + if (ProcessIdentity.MatchesTri(pid, identity) == false) return; foreach (var child in children) { if (!seen.Add(child)) continue; // handled on an earlier pass; a zombie stays listed until its parent dies From 6db281ffe04dd9cedb833c141e8fe44dee00666e Mon Sep 17 00:00:00 2001 From: realtonyyoung <6655045+realtonyyoung@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:31:24 -0400 Subject: [PATCH 6/6] Trust a child listing only when the root is checked on both sides of it Co-Authored-By: Claude Opus 5.5 --- src/Capacitor.Cli.Daemon/Services/ProcessTree.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs index fc1fb5d23d..659aa3a724 100644 --- a/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs +++ b/src/Capacitor.Cli.Daemon/Services/ProcessTree.cs @@ -79,12 +79,14 @@ static void KillSubtree(int pid, string identity) { var seen = new HashSet(); for (var rescan = 0; rescan <= MaxRescans; rescan++) { - var fresh = false; - var children = Children(pid).ToList(); + var fresh = false; - // A root proven to be a different incarnation after listing means the pid was reused, so the - // listed children may be the replacement's and are left alone. A root that merely exited - // (identity unreadable) still owned them when they were listed, so they are killed. + // The listing is trusted only when bracketed: the root must be the captured incarnation just + // before it and not proven a different one just after. A root that exited in between leaves + // its listed children to be killed, not orphaned; a pid proven reused leaves them alone. What + // remains is a reuse, plus the replacement spawning and exiting, inside one listing. + if (!ProcessIdentity.Matches(pid, identity)) break; + var children = Children(pid).ToList(); if (ProcessIdentity.MatchesTri(pid, identity) == false) return; foreach (var child in children) {