|
// 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.Runtime.Versioning;
using Microsoft.Extensions.Logging;
namespace Microsoft.DotNet.Watch;
internal class ProcessRunner(TimeSpan processCleanupTimeout)
{
private sealed class ProcessState(Process process) : IDisposable
{
public Process Process { get; } = process;
public int ProcessId;
public bool HasExited;
public void Dispose()
=> Process.Dispose();
}
// For testing purposes only, lock on access.
private static readonly HashSet<int> s_runningApplicationProcesses = [];
// Exit code used by the OS when process is terminated by an external signal.
private static readonly int s_processTerminatedExitCode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? unchecked((int)0xC000013A) : 137;
public static IReadOnlyCollection<int> GetRunningApplicationProcesses()
{
lock (s_runningApplicationProcesses)
{
return [.. s_runningApplicationProcesses];
}
}
/// <summary>
/// Launches a process.
/// Virutal for testing.
/// </summary>
public virtual async Task<int> RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
{
var stopwatch = new Stopwatch();
stopwatch.Start();
using var state = TryStartProcessImpl(processSpec, logger);
if (state == null)
{
return int.MinValue;
}
if (processSpec.IsUserApplication)
{
lock (s_runningApplicationProcesses)
{
s_runningApplicationProcesses.Add(state.ProcessId);
}
}
launchResult?.ProcessId = state.ProcessId;
int? exitCode = null;
try
{
try
{
await state.Process.WaitForExitAsync(processTerminationToken);
}
catch (OperationCanceledException)
{
// Process termination requested via cancellation token.
// Either Ctrl+C was pressed or the process is being restarted.
// Non-cancellable to not leave orphaned processes around blocking resources:
await TerminateProcessAsync(state.Process, processSpec, state, logger);
}
}
catch (Exception e)
{
if (processSpec.IsUserApplication)
{
logger.Log(MessageDescriptor.ApplicationFailed, e.Message);
}
}
finally
{
stopwatch.Stop();
if (processSpec.IsUserApplication)
{
lock (s_runningApplicationProcesses)
{
s_runningApplicationProcesses.Remove(state.ProcessId);
}
}
state.HasExited = true;
try
{
exitCode = state.Process.ExitCode;
}
catch
{
exitCode = null;
}
logger.Log(MessageDescriptor.ProcessRunAndExited, state.ProcessId, stopwatch.ElapsedMilliseconds, exitCode);
if (processSpec.IsUserApplication)
{
if (exitCode == 0 || exitCode == s_processTerminatedExitCode)
{
logger.Log(MessageDescriptor.Exited);
}
else if (exitCode == null)
{
logger.Log(MessageDescriptor.ExitedWithUnknownErrorCode);
}
else
{
logger.Log(MessageDescriptor.ExitedWithErrorCode, exitCode.Value);
}
}
if (processSpec.OnExit != null)
{
await processSpec.OnExit(state.ProcessId, exitCode);
}
}
return exitCode ?? int.MinValue;
}
internal static Process? TryStartProcess(ProcessSpec processSpec, ILogger logger)
=> TryStartProcessImpl(processSpec, logger)?.Process;
private static ProcessState? TryStartProcessImpl(ProcessSpec processSpec, ILogger logger)
{
var onOutput = processSpec.OnOutput;
var process = new Process
{
EnableRaisingEvents = true,
StartInfo =
{
FileName = processSpec.Executable,
UseShellExecute = processSpec.UseShellExecute,
WorkingDirectory = processSpec.WorkingDirectory,
RedirectStandardOutput = onOutput != null,
RedirectStandardError = onOutput != null,
}
};
var state = new ProcessState(process);
if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
process.StartInfo.CreateNewProcessGroup = true;
}
for (var i = 0; i < processSpec.Arguments.Count; i++)
{
process.StartInfo.ArgumentList.Add(processSpec.Arguments[i]);
}
foreach (var env in processSpec.EnvironmentVariables)
{
process.StartInfo.Environment.Add(env.Key, env.Value);
}
if (onOutput != null)
{
process.OutputDataReceived += (_, args) =>
{
try
{
if (args.Data != null)
{
onOutput(new OutputLine(args.Data, IsError: false));
}
}
catch (Exception e)
{
logger.Log(MessageDescriptor.ErrorReadingProcessOutput, "stdout", state.ProcessId, e.Message);
}
};
process.ErrorDataReceived += (_, args) =>
{
try
{
if (args.Data != null)
{
onOutput(new OutputLine(args.Data, IsError: true));
}
}
catch (Exception e)
{
logger.Log(MessageDescriptor.ErrorReadingProcessOutput, "stderr", state.ProcessId, e.Message);
}
};
}
var argsDisplay = processSpec.GetArgumentsDisplay();
try
{
if (!process.Start())
{
throw new InvalidOperationException("Process can't be started.");
}
state.ProcessId = process.Id;
if (onOutput != null)
{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId);
return state;
}
catch (Exception e)
{
logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, e.Message);
state.Dispose();
return null;
}
}
private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger)
{
var forceOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !processSpec.IsUserApplication;
TerminateProcess(process, state, logger, forceOnly);
if (forceOnly)
{
_ = await WaitForExitAsync(process, state, timeout: null, logger);
return;
}
// Ctlr+C/SIGTERM has been sent, wait for the process to exit gracefully.
if (processCleanupTimeout.TotalMilliseconds == 0 ||
!await WaitForExitAsync(process, state, processCleanupTimeout, logger))
{
// Force termination if the process is still running after the timeout.
TerminateProcess(process, state, logger, force: true);
_ = await WaitForExitAsync(process, state, timeout: null, logger);
}
}
private static async ValueTask<bool> WaitForExitAsync(Process process, ProcessState state, TimeSpan? timeout, ILogger logger)
{
// On Linux simple call WaitForExitAsync does not work reliably (it may hang).
// As a workaround we poll for HasExited.
// See also https://github.com/dotnet/runtime/issues/109434.
if (timeout.HasValue)
{
using var cancellationSource = new CancellationTokenSource();
cancellationSource.CancelAfter(timeout.Value);
try
{
logger.Log(MessageDescriptor.WaitingForProcessToExitWithin, state.ProcessId, (int)timeout.Value.TotalSeconds);
await process.WaitForExitAsync(cancellationSource.Token);
}
catch (OperationCanceledException)
{
try
{
return process.HasExited;
}
catch
{
return false;
}
}
}
else
{
int i = 1;
while (true)
{
try
{
if (process.HasExited)
{
return true;
}
}
catch
{
}
logger.Log(MessageDescriptor.WaitingForProcessToExit, state.ProcessId, i++);
using var cancellationSource = new CancellationTokenSource();
cancellationSource.CancelAfter(TimeSpan.FromSeconds(1));
try
{
await process.WaitForExitAsync(cancellationSource.Token);
break;
}
catch (OperationCanceledException)
{
}
}
}
return true;
}
private static void TerminateProcess(Process process, ProcessState state, ILogger logger, bool force)
{
try
{
if (!state.HasExited && !process.HasExited)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
TerminateWindowsProcess(process, state, logger, force);
}
else
{
TerminateUnixProcess(process, state, logger, force);
}
}
}
catch (Exception e)
{
logger.Log(MessageDescriptor.FailedToKillProcess, state.ProcessId, e.Message);
}
}
private static void TerminateWindowsProcess(Process process, ProcessState state, ILogger logger, bool force)
{
var signalName = force ? "Kill" : "Ctrl+C";
logger.Log(MessageDescriptor.TerminatingProcess, state.ProcessId, signalName);
if (force)
{
try
{
process.Kill();
}
catch (Exception e)
{
logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, e.Message);
}
}
else
{
var error = ProcessUtilities.SendWindowsCtrlCEvent(state.ProcessId);
if (error != null)
{
logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error);
}
}
}
[UnsupportedOSPlatform("windows")]
private static void TerminateUnixProcess(Process process, ProcessState state, ILogger logger, bool force)
{
var signal = force ? PosixSignal.SIGKILL : PosixSignal.SIGTERM;
var signalName = force ? "SIGKILL" : "SIGTERM";
logger.Log(MessageDescriptor.TerminatingProcess, state.ProcessId, signalName);
string? error = null;
try
{
process.SafeHandle.Signal(signal);
}
catch (Win32Exception ex)
{
// A process that has already exited is handled by Signal's non-exception return path.
// This catch is for exceptional failures, such as attempting to signal a process
// that we don't have permission to kill.
error = ex.Message;
}
if (error != null)
{
logger.Log(MessageDescriptor.FailedToSendSignalToProcess, signalName, state.ProcessId, error);
}
}
}
|