| File: src\Shared\ParentProcessLivenessMonitor.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. /// <summary> /// Watches a parent process (by PID plus start time) and invokes a caller-supplied action once when that /// parent is no longer running. Shared by the CLI's launcher monitor and the <c>aspire-managed</c> parent /// watchdog so both get the same poll loop and teardown semantics. /// </summary> internal sealed class ParentProcessLivenessMonitor : IAsyncDisposable { // The poll interval is intentionally coarse: this is a backstop for a parent that died abnormally, so // a one-second granularity is plenty and keeps the wakeups cheap. private static readonly TimeSpan s_pollInterval = TimeSpan.FromSeconds(1); private readonly CancellationTokenSource _stopCts = new(); private readonly Task _monitorTask; private int _disposed; private ParentProcessLivenessMonitor( int parentPid, long? parentStartedUnix, Func<CancellationToken, Task> onParentExited, TimeProvider timeProvider, bool useRuntimeStartTime) { _monitorTask = MonitorAsync(parentPid, parentStartedUnix, onParentExited, timeProvider, useRuntimeStartTime, _stopCts.Token); } /// <summary> /// Starts polling the parent identified by <paramref name="parentPid"/> (and, when supplied, /// <paramref name="parentStartedUnix"/> to guard against PID reuse). The parent is probed once /// immediately, then on every poll interval, so a parent that is already gone is detected without /// waiting a full tick. When the parent is gone, <paramref name="onParentExited"/> is invoked exactly /// once. The token passed to that callback is cancelled when this monitor is disposed, so any grace /// delay inside the callback is unwound if the caller disarms first. /// </summary> /// <remarks> /// <paramref name="parentStartedUnix"/> is interpreted per <paramref name="useRuntimeStartTime"/>: /// whole Unix seconds for the legacy <c>Process.StartTime</c> domain, or Unix milliseconds for the stable identity time. /// </remarks> public static ParentProcessLivenessMonitor Start( int parentPid, long? parentStartedUnix, Func<CancellationToken, Task> onParentExited, TimeProvider? timeProvider = null, bool useRuntimeStartTime = false) { return new ParentProcessLivenessMonitor(parentPid, parentStartedUnix, onParentExited, timeProvider ?? TimeProvider.System, useRuntimeStartTime); } private static async Task MonitorAsync( int parentPid, long? parentStartedUnix, Func<CancellationToken, Task> onParentExited, TimeProvider timeProvider, bool useRuntimeStartTime, CancellationToken stopToken) { try { // Hop off the caller's thread so Start() stays non-blocking (the original first operation was // an async timer await). The probe below then still runs on the very next scheduler turn // instead of after a full poll interval. await Task.Yield(); using var timer = new PeriodicTimer(s_pollInterval, timeProvider); // Probe once before awaiting the first tick. If the parent already died during our startup // window it is detected immediately instead of after a full poll interval, which closes the // gap where the parent is gone before the watchdog notices. This matches CliOrphanDetector, // whose do/while loop also checks once before it awaits its timer. See // src/Aspire.Hosting/Cli/CliOrphanDetector.cs. do { // Honor a disarm that raced with this probe: a disposed monitor must never invoke the // callback. The original loop guaranteed this by awaiting the (already-cancelled) timer // first; ThrowIfCancellationRequested preserves that when we probe before the timer. stopToken.ThrowIfCancellationRequested(); var isProcessRunning = useRuntimeStartTime && parentStartedUnix is { } legacyStartTime ? ProcessStartTimeHelper.IsProcessRunningWithRuntimeStartTime(parentPid, legacyStartTime, ProcessStartTimeHelper.LegacyStartTimeMatchTolerance) : ProcessStartTimeHelper.IsProcessRunning(parentPid, parentStartedUnix); if (isProcessRunning) { continue; } // Pass stopToken through so a callback that waits (e.g. a force-exit grace period) is // cancelled if the caller disposes the monitor while the callback is running. try { await onParentExited(stopToken).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch { // The callback is only a best-effort cleanup hook after the parent is gone. Don't // let callback failures fault monitor disposal paths that are already unwinding. } return; } while (await timer.WaitForNextTickAsync(stopToken).ConfigureAwait(false)); } catch (OperationCanceledException) { // Disarmed (stopped/disposed) before the parent exited — the expected, common case. } } public async ValueTask DisposeAsync() { // Idempotent: callers may dispose on a happy path and again from an outer finally backstop. if (Interlocked.Exchange(ref _disposed, 1) != 0) { return; } _stopCts.Cancel(); try { await _monitorTask.ConfigureAwait(false); } catch (OperationCanceledException) { // Expected when stopping the monitor loop. } finally { _stopCts.Dispose(); } } }