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