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