Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions backend/src/CodeSpace.RunnerHost/Protocol/NativeProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}

/// <summary>
Expand Down Expand Up @@ -207,8 +211,13 @@ public static void KillSession(NativeProcessIdentity identity)
/// <see cref="DirectoryNotFoundException"/> reports an instant later, so it must not surface as "unknowable". The
/// evidence is the kernel's own answer (<see cref="IsAbsent"/>), never the exception's message: any other
/// <see cref="IOException"/> about a pid that still exists (EIO, EMFILE) stays unknowable and reaches the caller.
///
/// <para>The same holds one step later, when the read is the runtime's instead of the kernel's: a process that exits after
/// <c>stat</c> was read but before <see cref="Process.GetProcessById(int)"/> or <see cref="Process.StartTime"/> asks for it is refused
/// with an <see cref="ArgumentException"/> ("not running") or a <see cref="Win32Exception"/> ("may have exited or may be
/// privileged"). The second message names the ambiguity, so both count as gone only on the probe's evidence.</para>
/// </summary>
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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;
using CodeSpace.NativeLaunch;
using Shouldly;
Expand All @@ -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).</para>
///
/// <para><c>Identify</c> asks the runtime for the process AFTER that read, and a process that exits in between is refused
/// there instead: <see cref="Process.GetProcessById(int)"/> throws <see cref="ArgumentException"/> and
/// <see cref="Process.StartTime"/> throws <see cref="Win32Exception"/>. 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.</para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class NativeProcessGoneTests
Expand All @@ -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;

Expand All @@ -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<ArgumentException>(() => 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<Win32Exception>(() => 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()
{
Expand Down
Loading