File: src\Shared\ProcessStartTimeHelper.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
 
/// <summary>
/// Dependency-free helpers for identifying a process by PID plus its start time, used by the
/// various orphan/parent-liveness watchdogs that must survive PID reuse.
/// </summary>
internal static partial class ProcessStartTimeHelper
{
    internal const string LinuxProcRoot = "/proc";
 
    private const int DefaultLinuxClockTicksPerSecond = 100;
    private const int LinuxClockTicksPerSecondConfigName = 2; // _SC_CLK_TCK
 
    /// <summary>
    /// The tolerance legacy callers apply when comparing a <see cref="Process.StartTime"/>-derived
    /// start time across processes. It absorbs the boot-time jitter that makes the same PID's runtime
    /// start time differ between readers on Linux. The stable millisecond identity is far tighter — see
    /// <see cref="StableStartTimeMatchTolerance"/>.
    /// </summary>
    public static readonly TimeSpan LegacyStartTimeMatchTolerance = TimeSpan.FromSeconds(1);
 
    /// <summary>
    /// The tolerance callers apply when comparing the stable (millisecond) process-identity start time.
    /// </summary>
    // The stable identity is only as precise as its coarsest source. On Linux the /proc start time is
    // expressed in clock ticks whose resolution is sysconf(_SC_CLK_TCK) — effectively 100 Hz / 10 ms
    // (USER_HZ) on every mainstream distro. 20 ms is two of those ticks: it absorbs a one-tick rounding
    // difference on each side of a tick boundary while still shrinking the PID-reuse window ~50x versus
    // the previous whole-second identity. Windows (100 ns FILETIME) and macOS (1 us) are far finer, 
    // so 20 ms is comfortably within their resolution too.
    public static readonly TimeSpan StableStartTimeMatchTolerance = TimeSpan.FromMilliseconds(20);
 
    /// <summary>
    /// Gets the current process's stable identity start time as Unix milliseconds. This is the value
    /// that should be propagated to child processes so they can verify the parent's identity
    /// (PID + start time) at millisecond granularity.
    /// </summary>
    public static long GetCurrentProcessStartTimeUnixMilliseconds()
        => GetCurrentProcessStartTime().ToUnixTimeMilliseconds();
 
    /// <summary>
    /// Gets the current process's start time as reported by <see cref="Process.StartTime"/>.
    /// </summary>
    public static long GetCurrentProcessRuntimeStartTimeUnixSeconds()
    {
        using var process = Process.GetCurrentProcess();
        return new DateTimeOffset(process.StartTime).ToUnixTimeSeconds();
    }
 
    /// <summary>
    /// Gets the current process's start time using the same platform-specific source as
    /// <see cref="TryGetProcessStartTime"/>.
    /// </summary>
    public static DateTimeOffset GetCurrentProcessStartTime()
    {
        if (TryGetProcessStartTime(Environment.ProcessId) is { } startTime)
        {
            return startTime;
        }
 
        using var process = Process.GetCurrentProcess();
        return new DateTimeOffset(process.StartTime);
    }
 
    /// <summary>
    /// Gets the stable identity start time, as Unix milliseconds, of the process with the given
    /// <paramref name="pid"/>.
    /// </summary>
    /// <returns>The start time, or <see langword="null"/> when the process cannot be inspected (already exited, privileged, etc.).</returns>
    public static long? TryGetProcessStartTimeUnixMilliseconds(int pid)
        => TryGetProcessStartTime(pid)?.ToUnixTimeMilliseconds();
 
    /// <summary>
    /// Gets the start time, as whole Unix seconds, of the process with the given <paramref name="pid"/>
    /// using <see cref="Process.StartTime"/>.
    /// </summary>
    /// <returns>The start time, or <see langword="null"/> when the process cannot be inspected (already exited, privileged, etc.).</returns>
    public static long? TryGetRuntimeProcessStartTimeUnixSeconds(int pid)
    {
        try
        {
            using var process = Process.GetProcessById(pid);
            return new DateTimeOffset(process.StartTime).ToUnixTimeSeconds();
        }
        catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception)
        {
            return null;
        }
    }
 
    /// <summary>
    /// Gets the start time of the process with the given <paramref name="pid"/>.
    /// </summary>
    /// <returns>The start time, or <see langword="null"/> when the process cannot be inspected (already exited, privileged, etc.).</returns>
    public static DateTimeOffset? TryGetProcessStartTime(int pid)
    {
        if (OperatingSystem.IsLinux())
        {
            return TryGetLinuxProcessStartTime(pid);
        }
 
        try
        {
            using var process = Process.GetProcessById(pid);
            return new DateTimeOffset(process.StartTime);
        }
        catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or Win32Exception)
        {
            return null;
        }
    }
 
    /// <summary>
    /// Parses a Unix start-time value previously produced for an identity environment variable (whole
    /// seconds for the legacy domain, milliseconds for the stable domain).
    /// </summary>
    /// <returns>The parsed value, or <see langword="null"/> when <paramref name="value"/> is missing or invalid.</returns>
    public static long? TryParseStartTimeUnixSeconds(string? value)
        => long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
 
    /// <summary>
    /// Determines whether a process with the given <paramref name="pid"/> is currently running and,
    /// when <paramref name="expectedStartTimeUnixMilliseconds"/> is supplied, whether its stable start
    /// time matches (guarding against PID reuse). When the expected start time is <see langword="null"/>
    /// this falls back to a PID-only existence check.
    /// </summary>
    /// <param name="pid">The process ID to check.</param>
    /// <param name="expectedStartTimeUnixMilliseconds">The expected stable start time (Unix milliseconds), or <see langword="null"/> for PID-only.</param>
    /// <param name="tolerance">Allowed difference between the expected and observed start time. Defaults to <see cref="StableStartTimeMatchTolerance"/>.</param>
    /// <param name="assumeRunningWhenUnableToInspect">
    /// Whether an inaccessible process should be treated as running. Cleanup callers use this
    /// conservative mode so an inspection failure never authorizes deletion.
    /// </param>
    /// <returns><see langword="true"/> if the process exists and matches; otherwise <see langword="false"/>.</returns>
    public static bool IsProcessRunning(
        int pid,
        long? expectedStartTimeUnixMilliseconds = null,
        TimeSpan? tolerance = null,
        bool assumeRunningWhenUnableToInspect = false)
        => IsProcessRunning(
            pid,
            expectedStartTimeUnixMilliseconds,
            tolerance,
            assumeRunningWhenUnableToInspect,
            out _);
 
    /// <summary>
    /// Determines whether a process is running and reports when the result used the configured
    /// uncertainty bias because the process identity could not be inspected.
    /// </summary>
    public static bool IsProcessRunning(
        int pid,
        long? expectedStartTimeUnixMilliseconds,
        TimeSpan? tolerance,
        bool assumeRunningWhenUnableToInspect,
        out bool unableToInspect)
    {
        unableToInspect = false;
 
        try
        {
            using var process = Process.GetProcessById(pid);
            if (process.HasExited)
            {
                return false;
            }
 
            if (expectedStartTimeUnixMilliseconds is { } expected)
            {
                // Reading the process start time can race with process exit. On macOS it can throw:
                //   Win32Exception (3): Unable to retrieve the specified information about the process or thread.
                // If we cannot prove this is the expected target, honor the caller's explicit uncertainty
                // bias: watchdogs report it gone, while destructive cleanup assumes it is still running.
                var actual = TryGetProcessStartTimeUnixMilliseconds(pid);
                if (actual is null)
                {
                    unableToInspect = true;
                    return assumeRunningWhenUnableToInspect;
                }
 
                if (!AreCloseMilliseconds(expected, actual.Value, tolerance))
                {
                    return false;
                }
 
                if (process.HasExited)
                {
                    return false;
                }
            }
 
            return true;
        }
        catch (ArgumentException)
        {
            // Process doesn't exist - already terminated.
            return false;
        }
        catch (InvalidOperationException)
        {
            // Process has already exited.
            return false;
        }
        catch (Win32Exception)
        {
            // Could not inspect the process (e.g. it exited mid-check, or is privileged).
            unableToInspect = true;
            return assumeRunningWhenUnableToInspect;
        }
    }
 
    /// <summary>
    /// Determines whether a process matches a legacy start time that was produced from
    /// <see cref="Process.StartTime"/>. Cross-process callers should pass a one-second
    /// <paramref name="tolerance"/> because on Linux <see cref="Process.StartTime"/> is reconstructed from
    /// an independently sampled boot time and can differ by <see cref="LegacyStartTimeMatchTolerance"/>
    /// between processes reading the same PID.
    /// </summary>
    /// <param name="pid">The process ID to check.</param>
    /// <param name="expectedStartTimeUnixSeconds">The expected legacy start time (whole Unix seconds).</param>
    /// <param name="tolerance">
    /// Allowed difference between the expected and observed start time. Defaults to an exact match; the
    /// cross-process orphan/liveness callers pass <see cref="LegacyStartTimeMatchTolerance"/> to absorb
    /// the boot-time jitter described above.
    /// </param>
    /// <returns><see langword="true"/> if the process exists and its legacy start time matches; otherwise <see langword="false"/>.</returns>
    public static bool IsProcessRunningWithRuntimeStartTime(int pid, long expectedStartTimeUnixSeconds, TimeSpan? tolerance = null)
    {
        try
        {
            using var process = Process.GetProcessById(pid);
            if (process.HasExited)
            {
                return false;
            }
 
            if (TryGetRuntimeProcessStartTimeUnixSeconds(pid) is not { } actual ||
                !AreClose(expectedStartTimeUnixSeconds, actual, tolerance))
            {
                return false;
            }
 
            return !process.HasExited;
        }
        catch (ArgumentException)
        {
            // Process doesn't exist - already terminated.
            return false;
        }
        catch (InvalidOperationException)
        {
            // Process has already exited.
            return false;
        }
        catch (Win32Exception)
        {
            // Could not inspect the process (e.g. it exited mid-check, or is privileged). Without
            // proof of identity, do not report it as the expected running process.
            return false;
        }
    }
 
    /// <summary>
    /// Returns whether two start times, expressed as whole Unix seconds, identify the same process.
    /// The comparison is exact by default: both values are already truncated to whole seconds, so
    /// accepting an adjacent second would let a PID recycled into the neighboring second impersonate the
    /// original process and defeat the reuse guard.
    /// </summary>
    /// <param name="expectedStartTimeUnixSeconds">The expected start time, in whole Unix seconds.</param>
    /// <param name="actualStartTimeUnixSeconds">The observed start time, in whole Unix seconds.</param>
    /// <param name="tolerance">
    /// Optional allowed difference between the two values. Defaults to an exact match. Callers should only
    /// opt into a non-zero tolerance when they must absorb cross-process jitter in the OS-reported start time. 
    /// </param>
    public static bool AreClose(long expectedStartTimeUnixSeconds, long actualStartTimeUnixSeconds, TimeSpan? tolerance = null)
    {
        var toleranceSeconds = tolerance is { } value ? (long)value.TotalSeconds : 0;
        return Math.Abs(expectedStartTimeUnixSeconds - actualStartTimeUnixSeconds) <= toleranceSeconds;
    }
 
    /// <summary>
    /// Returns whether two stable-identity start times, expressed as Unix milliseconds, identify the same
    /// process. Defaults to <see cref="StableStartTimeMatchTolerance"/> so a within-tick rounding
    /// difference is accepted while a recycled PID (which starts a later, larger boot-relative time) is
    /// still rejected.
    /// </summary>
    /// <param name="expectedStartTimeUnixMilliseconds">The expected start time, in Unix milliseconds.</param>
    /// <param name="actualStartTimeUnixMilliseconds">The observed start time, in Unix milliseconds.</param>
    /// <param name="tolerance">Optional allowed difference; defaults to <see cref="StableStartTimeMatchTolerance"/>.</param>
    public static bool AreCloseMilliseconds(long expectedStartTimeUnixMilliseconds, long actualStartTimeUnixMilliseconds, TimeSpan? tolerance = null)
    {
        var toleranceMilliseconds = (long)(tolerance ?? StableStartTimeMatchTolerance).TotalMilliseconds;
        return Math.Abs(expectedStartTimeUnixMilliseconds - actualStartTimeUnixMilliseconds) <= toleranceMilliseconds;
    }
 
    private static DateTimeOffset? TryGetLinuxProcessStartTime(int pid)
    {
        // These identities are always stamped with PIDs from the current process namespace.
        // Do not honor HOST_PROC here: that is only for DCP host-process inspection.
        if (TryGetLinuxProcessStartTicks(pid, LinuxProcRoot) is not { } startTicks)
        {
            return null;
        }
 
        // Boot-relative milliseconds. startTicks is field 22 of /proc/<pid>/stat, fixed at process start
        // relative to boot, so this is identical for every reader regardless of any later wall-clock step.
        // We intentionally do NOT add /proc/stat btime: btime re-expresses this as wall-clock time and
        // itself shifts on clock sync, which would re-introduce the drift this guards against. The result
        // is an opaque same-machine identity token (milliseconds since boot), not a real calendar time.
        // Milliseconds (startTicks * 1000 / _SC_CLK_TCK) preserves the sub-second precision needed to
        // detect a PID reused within the same second; the source resolution is one _SC_CLK_TCK tick (~10 ms). 
        var startTimeMillisecondsSinceBoot = (long)((startTicks * 1000) / (ulong)GetLinuxClockTicksPerSecond());
        return DateTimeOffset.FromUnixTimeMilliseconds(startTimeMillisecondsSinceBoot);
    }
 
    /// <summary>
    /// Reads the raw start time of the process with the given <paramref name="pid"/> as clock ticks since
    /// boot (field 22 of <c>/proc/&lt;pid&gt;/stat</c>). This is the drift-immune building block for Linux
    /// process identity: the value is fixed at process start relative to boot, so every reader on the
    /// machine observes the same number even after a wall-clock adjustment. 
    /// Divide by <see cref="GetLinuxClockTicksPerSecond"/> to obtain seconds since boot.
    /// </summary>
    /// <remarks>
    /// Using this is especially important for containers, where suspension and resumption of the host can cause
    /// the wall clock to jump. 
    /// </remarks>
    /// <param name="pid">The process to inspect.</param>
    /// <param name="procRoot"> The <c>/proc</c> filesystem root to read from. 
    /// Normally /proc, but can be overridden for container environments to read the host process namespace.
    /// </param>
    /// <returns>The start ticks, or <see langword="null"/> when the stat file cannot be read or parsed.</returns>
    internal static ulong? TryGetLinuxProcessStartTicks(int pid, string procRoot)
    {
        var statPath = Path.Combine(procRoot, pid.ToString(CultureInfo.InvariantCulture), "stat");
 
        string contents;
        try
        {
            contents = File.ReadAllText(statPath);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            return null;
        }
 
        return TryParseLinuxStatStartTicks(contents);
    }
 
    internal static ulong? TryParseLinuxStatStartTicks(string contents)
    {
        // /proc/<pid>/stat fields start as:
        //   12345 (process name may contain spaces or parentheses) S 1 2 3 ...
        // The process start time is field 22, in clock ticks since boot. Split after the final
        // ')' so process names containing spaces or parentheses do not shift the field indexes.
        var closeParenIndex = contents.LastIndexOf(')');
        if (closeParenIndex < 0 || closeParenIndex + 2 >= contents.Length)
        {
            return null;
        }
 
        var fields = contents[(closeParenIndex + 2)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
        return fields.Length >= 20 && ulong.TryParse(fields[19], NumberStyles.None, CultureInfo.InvariantCulture, out var startTicks)
            ? startTicks
            : null;
    }
 
    /// <summary>
    /// Gets the Linux clock resolution (<c>_SC_CLK_TCK</c>) used to convert <c>/proc</c> start ticks to
    /// seconds. Shared so every Linux process-identity computation (including <c>DcpProcessMonitor</c>) uses
    /// the same value.
    /// </summary>
    internal static int GetLinuxClockTicksPerSecond()
    {
        var result = sysconf(LinuxClockTicksPerSecondConfigName);
        return result > 0 ? (int)result : DefaultLinuxClockTicksPerSecond;
    }
 
    [LibraryImport("libc", SetLastError = true, EntryPoint = "sysconf")]
    private static partial long sysconf(int name);
}