diff --git a/backend/src/CodeSpace.RunnerHost/Protocol/NativeProcess.cs b/backend/src/CodeSpace.RunnerHost/Protocol/NativeProcess.cs
index f3927d718..e4ea1633f 100644
--- a/backend/src/CodeSpace.RunnerHost/Protocol/NativeProcess.cs
+++ b/backend/src/CodeSpace.RunnerHost/Protocol/NativeProcess.cs
@@ -21,8 +21,12 @@ public static NativeProcessIdentity Identify(int pid)
try { fields = LinuxProcessFields(pid); }
catch (IOException error) when (TreatsAsGone(pid, error)) { throw new IOException("Cannot identify a terminated native process.", error); }
if (fields[0] is "Z" or "X") throw new IOException("Cannot identify a terminated native process.");
- using var process = Process.GetProcessById(pid);
- return new NativeProcessIdentity(pid, process.StartTime.ToUniversalTime().Ticks, BootId, "linux:" + fields[19]);
+ try
+ {
+ using var process = Process.GetProcessById(pid);
+ return new NativeProcessIdentity(pid, process.StartTime.ToUniversalTime().Ticks, BootId, "linux:" + fields[19]);
+ }
+ catch (Exception error) when (TreatsAsGone(pid, error)) { throw new IOException("Cannot identify a terminated native process.", error); }
}
///
@@ -207,8 +211,13 @@ public static void KillSession(NativeProcessIdentity identity)
/// reports an instant later, so it must not surface as "unknowable". The
/// evidence is the kernel's own answer (), never the exception's message: any other
/// about a pid that still exists (EIO, EMFILE) stays unknowable and reaches the caller.
+ ///
+ /// The same holds one step later, when the read is the runtime's instead of the kernel's: a process that exits after
+ /// stat was read but before or asks for it is refused
+ /// with an ("not running") or a ("may have exited or may be
+ /// privileged"). The second message names the ambiguity, so both count as gone only on the probe's evidence.
///
- internal static bool TreatsAsGone(int pid, Exception failure) => failure is IOException && IsAbsent(pid);
+ internal static bool TreatsAsGone(int pid, Exception failure) => failure is (IOException or ArgumentException or Win32Exception) && IsAbsent(pid);
public static bool Same(NativeProcessIdentity left, NativeProcessIdentity right) => left.ProcessId == right.ProcessId && left.BootId == right.BootId && !string.IsNullOrEmpty(left.StartKey) && left.StartKey == right.StartKey;
diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/NativeProcessGoneTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/NativeProcessGoneTests.cs
index 016bdc3c1..cb878803d 100644
--- a/backend/tests/CodeSpace.UnitTests/Workflows/NativeProcessGoneTests.cs
+++ b/backend/tests/CodeSpace.UnitTests/Workflows/NativeProcessGoneTests.cs
@@ -1,3 +1,4 @@
+using System.ComponentModel;
using System.Diagnostics;
using CodeSpace.NativeLaunch;
using Shouldly;
@@ -14,6 +15,11 @@ namespace CodeSpace.UnitTests.Workflows;
/// such an exception to is pinned instead — "gone" must never surface as "unknowable" (a poll of a just-killed
/// supervisor threw exactly this once on Linux CI), and "unknowable" must never be folded into "gone" (that would
/// abandon a live run).
+///
+/// Identify asks the runtime for the process AFTER that read, and a process that exits in between is refused
+/// there instead: throws and
+/// throws . The same classification covers both — the
+/// kernel's answer decides, never the exception's type — and the real exceptions are fed to it, not only built ones.
///
[Trait("Category", "Unit")]
public sealed class NativeProcessGoneTests
@@ -38,10 +44,13 @@ public void A_plain_IOException_is_gone_exactly_when_the_kernel_says_the_pid_is_
[InlineData(typeof(IOException), true)]
[InlineData(typeof(FileNotFoundException), true)]
[InlineData(typeof(DirectoryNotFoundException), true)]
+ // What Identify's two runtime calls raise for a process that exits after its stat read: "not running" from GetProcessById, "information unavailable" from StartTime.
+ [InlineData(typeof(ArgumentException), true)]
+ [InlineData(typeof(Win32Exception), true)]
// Nothing else is ever answered by the probe: a malformed stat or a denied read is a defect to surface, not a death.
[InlineData(typeof(InvalidDataException), false)]
[InlineData(typeof(UnauthorizedAccessException), false)]
- public void Only_an_IO_failure_about_an_absent_pid_reads_as_a_process_that_is_gone(Type failureType, bool expectedGone)
+ public void Only_a_failure_to_read_a_process_about_an_absent_pid_reads_as_a_process_that_is_gone(Type failureType, bool expectedGone)
{
if (OperatingSystem.IsWindows()) return;
@@ -50,6 +59,48 @@ public void Only_an_IO_failure_about_an_absent_pid_reads_as_a_process_that_is_go
NativeProcess.TreatsAsGone(ReapedPid(), failure).ShouldBe(expectedGone, $"{failureType.Name} about a pid that names nothing");
}
+ [Theory]
+ // The kernel decides, never the type: what reads as gone for an absent pid proves nothing about one that still exists.
+ // (Win32Exception's own message says why: "It may have exited or may be privileged.")
+ [InlineData(typeof(IOException))]
+ [InlineData(typeof(ArgumentException))]
+ [InlineData(typeof(Win32Exception))]
+ public void A_failure_about_a_pid_that_still_exists_is_never_gone_whatever_its_type(Type failureType)
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ var failure = (Exception)Activator.CreateInstance(failureType)!;
+
+ NativeProcess.TreatsAsGone(Environment.ProcessId, failure).ShouldBeFalse($"{failureType.Name} about pid {Environment.ProcessId}, which is running this test");
+ }
+
+ [Fact]
+ public void The_refusal_GetProcessById_raises_for_a_process_that_has_gone_is_one_TreatsAsGone_accepts()
+ {
+ if (OperatingSystem.IsWindows()) return;
+
+ var pid = ReapedPid();
+ var refusal = Should.Throw(() => Process.GetProcessById(pid));
+
+ NativeProcess.TreatsAsGone(pid, refusal).ShouldBeTrue("the runtime's own refusal of a pid that names nothing, not a hand-built one: what Identify meets when the process exits after its stat read");
+ }
+
+ [Fact]
+ public void The_refusal_StartTime_raises_for_a_process_that_exited_after_it_was_opened_is_one_TreatsAsGone_accepts()
+ {
+ // Only Linux's Identify reads StartTime (Darwin reads libproc), and this is Linux's runtime reading a vanished /proc entry.
+ if (!OperatingSystem.IsLinux()) return;
+
+ using var child = Process.Start(new ProcessStartInfo { FileName = "/bin/sh", ArgumentList = { "-c", "read line" }, UseShellExecute = false, RedirectStandardInput = true })!;
+ using var opened = Process.GetProcessById(child.Id);
+ child.StandardInput.Close();
+ child.WaitForExit(30_000).ShouldBeTrue($"fixture check: pid {child.Id} must exit once its stdin closes — check `ps -p {child.Id}` by hand");
+
+ var refusal = Should.Throw(() => opened.StartTime, $"fixture check: pid {child.Id} was opened alive and has since exited, so reading its start time must fail the way Identify's race does");
+
+ NativeProcess.TreatsAsGone(child.Id, refusal).ShouldBeTrue("the runtime's own refusal of a process that exited after it was opened, not a hand-built one");
+ }
+
[Fact]
public void Both_liveness_predicates_answer_gone_for_a_process_that_has_exited()
{