Skip to content
35 changes: 34 additions & 1 deletion src/Capacitor.Cli.Daemon/Services/ProcessTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,46 @@ public static void Kill(int pid) {
if (ProcessIdentity.Capture(pid) is { } identity) KillSubtree(pid, identity);
}

/// <summary>Kills only while <paramref name="pid"/> still carries <paramref name="identity"/>. Use
/// when the caller captured the identity itself — otherwise the bare <see cref="Kill(int)"/> 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.</summary>
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);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

static void KillSubtree(int pid, string identity) {
var seen = new HashSet<int>();

for (var rescan = 0; rescan <= MaxRescans; rescan++) {
var fresh = false;

foreach (var child in Children(pid)) {
// 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) {
if (!seen.Add(child)) continue; // handled on an earlier pass; a zombie stays listed until its parent dies

fresh = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,74 @@ 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 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'\nsleep 20 &\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();
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, marker);
}
}

/// <summary>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.</summary>
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
}

// 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));
}

static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'";

static async Task<string> 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;
}
}
Loading