diff --git a/LittleForker.sln b/LittleForker.sln index 1e1d8d3..6e7321f 100644 --- a/LittleForker.sln +++ b/LittleForker.sln @@ -8,8 +8,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LittleForker.Tests", "src\L EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NonTerminatingProcess", "src\NonTerminatingProcess\NonTerminatingProcess.csproj", "{D68ABF78-2FC6-4EBD-AAAB-742637896206}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SelfTerminatingProcess", "src\SelfTerminatingProcess\SelfTerminatingProcess.csproj", "{F85DBC32-C585-4941-A210-5DE05A231BDF}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -28,10 +26,6 @@ Global {D68ABF78-2FC6-4EBD-AAAB-742637896206}.Debug|Any CPU.Build.0 = Debug|Any CPU {D68ABF78-2FC6-4EBD-AAAB-742637896206}.Release|Any CPU.ActiveCfg = Release|Any CPU {D68ABF78-2FC6-4EBD-AAAB-742637896206}.Release|Any CPU.Build.0 = Release|Any CPU - {F85DBC32-C585-4941-A210-5DE05A231BDF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F85DBC32-C585-4941-A210-5DE05A231BDF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F85DBC32-C585-4941-A210-5DE05A231BDF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F85DBC32-C585-4941-A210-5DE05A231BDF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/LittleForker.Tests/CooperativeShutdownTests.cs b/src/LittleForker.Tests/CooperativeShutdownTests.cs index 53a7e38..f25db1d 100644 --- a/src/LittleForker.Tests/CooperativeShutdownTests.cs +++ b/src/LittleForker.Tests/CooperativeShutdownTests.cs @@ -1,35 +1,58 @@ using System; -using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Shouldly; +using Microsoft.Extensions.Options; using Xunit; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class CooperativeShutdownTests { - public class CooperativeShutdownTests + private readonly ILoggerFactory _loggerFactory; + + public CooperativeShutdownTests(ITestOutputHelper outputHelper) { - private readonly ILoggerFactory _loggerFactory; + _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; + } - public CooperativeShutdownTests(ITestOutputHelper outputHelper) + [Fact] + public async Task When_server_signals_exit_then_should_notify_client_to_exit() + { + var applicationLifetime = new FakeHostApplicationLifetime(); + var options = new CooperativeShutdownHostedServiceOptions() { - _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; - } + PipeName = Guid.NewGuid().ToString() + }; + var cooperativeShutdownHostedService = new CooperativeShutdownHostedService( + applicationLifetime, + Options.Create(options), + _loggerFactory.CreateLogger()); - [Fact] - public async Task When_server_signals_exit_then_should_notify_client_to_exit() - { - var exitCalled = new TaskCompletionSource(); - var listener = await CooperativeShutdown.Listen( - () => exitCalled.SetResult(true), - _loggerFactory); + await cooperativeShutdownHostedService.StartAsync(CancellationToken.None); - await CooperativeShutdown.SignalExit(Process.GetCurrentProcess().Id, _loggerFactory); + await CooperativeShutdown.SignalExit(options.PipeName, _loggerFactory); - (await exitCalled.Task).ShouldBeTrue(); + await applicationLifetime.StopApplicationCalled.TimeoutAfter(TimeSpan.FromSeconds(5)); - listener.Dispose(); + await cooperativeShutdownHostedService.StopAsync(CancellationToken.None); + } + + + private class FakeHostApplicationLifetime : IHostApplicationLifetime + { + private readonly TaskCompletionSource _stopApplicationCalled = new(); + public CancellationToken ApplicationStarted => throw new NotImplementedException(); + public CancellationToken ApplicationStopping => throw new NotImplementedException(); + public CancellationToken ApplicationStopped => throw new NotImplementedException(); + + public void StopApplication() + { + _stopApplicationCalled.SetResult(); } + + public Task StopApplicationCalled => _stopApplicationCalled.Task; } -} +} \ No newline at end of file diff --git a/src/LittleForker.Tests/Infra/TestOutputHelperExtensions.cs b/src/LittleForker.Tests/Infra/TestOutputHelperExtensions.cs index d370972..2e73477 100644 --- a/src/LittleForker.Tests/Infra/TestOutputHelperExtensions.cs +++ b/src/LittleForker.Tests/Infra/TestOutputHelperExtensions.cs @@ -1,15 +1,14 @@ using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public static class TestOutputHelperExtensions { - public static class TestOutputHelperExtensions + public static void WriteLine2(this ITestOutputHelper outputHelper, object o) { - public static void WriteLine2(this ITestOutputHelper outputHelper, object o) + if (o != null) { - if (o != null) - { - outputHelper.WriteLine(o.ToString()); - } + outputHelper.WriteLine(o.ToString()); } } } \ No newline at end of file diff --git a/src/LittleForker.Tests/LittleForker.Tests.csproj b/src/LittleForker.Tests/LittleForker.Tests.csproj index a8e46d4..844a283 100644 --- a/src/LittleForker.Tests/LittleForker.Tests.csproj +++ b/src/LittleForker.Tests/LittleForker.Tests.csproj @@ -28,9 +28,6 @@ PreserveNewest - - PreserveNewest - diff --git a/src/LittleForker.Tests/ProcessExitedHelperTests.cs b/src/LittleForker.Tests/ProcessExitedHelperTests.cs index de2ae2b..05ba2a4 100644 --- a/src/LittleForker.Tests/ProcessExitedHelperTests.cs +++ b/src/LittleForker.Tests/ProcessExitedHelperTests.cs @@ -5,89 +5,88 @@ using Xunit; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class ProcessExitedHelperTests { - public class ProcessExitedHelperTests + private readonly ITestOutputHelper _outputHelper; + private readonly ILoggerFactory _loggerFactory; + + public ProcessExitedHelperTests(ITestOutputHelper outputHelper) { - private readonly ITestOutputHelper _outputHelper; - private readonly ILoggerFactory _loggerFactory; + _outputHelper = outputHelper; + _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; + } - public ProcessExitedHelperTests(ITestOutputHelper outputHelper) + [Fact] + public async Task When_parent_process_does_not_exist_then_should_call_parent_exited_callback() + { + var parentExited = new TaskCompletionSource(); + using (new ProcessExitedHelper(-1, watcher => parentExited.SetResult(watcher.ProcessId), _loggerFactory)) { - _outputHelper = outputHelper; - _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; + var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); + processId.ShouldBe(-1); } + } - [Fact] - public async Task When_parent_process_does_not_exist_then_should_call_parent_exited_callback() - { - var parentExited = new TaskCompletionSource(); - using (new ProcessExitedHelper(-1, watcher => parentExited.SetResult(watcher.ProcessId), _loggerFactory)) + [Fact] + public async Task When_parent_process_exits_than_should_call_parent_exited_callback() + { + // Start parent + var settings = + new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); - processId.ShouldBe(-1); - } - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + var parentIsRunning = supervisor.WhenStateIs(ProcessSupervisor.State.Running); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Parent Process: {data}"); + await supervisor.Start(); + await parentIsRunning; - [Fact] - public async Task When_parent_process_exits_than_should_call_parent_exited_callback() + // Monitor parent + var parentExited = new TaskCompletionSource(); + using (new ProcessExitedHelper(supervisor.ProcessInfo.Id, watcher => parentExited.SetResult(watcher.ProcessId), _loggerFactory)) { - // Start parent - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); - var parentIsRunning = supervisor.WhenStateIs(ProcessSupervisor.State.Running); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Parent Process: {data}"); - await supervisor.Start(); - await parentIsRunning; + // Stop parent + await supervisor.Stop(TimeSpan.FromSeconds(2)); + var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); + processId.Value.ShouldBeGreaterThan(0); + } + } - // Monitor parent - var parentExited = new TaskCompletionSource(); - using (new ProcessExitedHelper(supervisor.ProcessInfo.Id, watcher => parentExited.SetResult(watcher.ProcessId), _loggerFactory)) + [Fact] + public async Task When_parent_process_exits_then_child_process_should_also_do_so() + { + // Start parent + var parentSettings = + new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - // Stop parent - await supervisor.Stop(TimeSpan.FromSeconds(2)); - var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); - processId.Value.ShouldBeGreaterThan(0); - } - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; - [Fact] - public async Task When_parent_process_exits_then_child_process_should_also_do_so() - { - // Start parent - var parentSupervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); - parentSupervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Parent: {data}"); - var parentIsRunning = parentSupervisor.WhenStateIs(ProcessSupervisor.State.Running); - await parentSupervisor.Start(); - await parentIsRunning; + var parentSupervisor = new ProcessSupervisor(parentSettings, _loggerFactory); + parentSupervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Parent: {data}"); + var parentIsRunning = parentSupervisor.WhenStateIs(ProcessSupervisor.State.Running); + await parentSupervisor.Start(); + await parentIsRunning; - // Start child - var childSupervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.SelfTerminating, - Environment.CurrentDirectory, - "dotnet", - $"./NonTerminatingProcess/NonTerminatingProcess.dll --ParentProcessId={parentSupervisor.ProcessInfo.Id}"); - childSupervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Child: {data}"); - var childIsRunning = childSupervisor.WhenStateIs(ProcessSupervisor.State.Running); - var childHasStopped = childSupervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); - await childSupervisor.Start(); - await childIsRunning; + // Start child + var childSettings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") + { + Arguments = $"./NonTerminatingProcess/NonTerminatingProcess.dll --ParentProcessId={parentSupervisor.ProcessInfo.Id}" + }; + var childSupervisor = new ProcessSupervisor(childSettings, _loggerFactory); + childSupervisor.OutputDataReceived += data => _outputHelper.WriteLine($"Child: {data}"); + var childIsRunning = childSupervisor.WhenStateIs(ProcessSupervisor.State.Running); + var childHasStopped = childSupervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); + await childSupervisor.Start(); + await childIsRunning; - // Stop parent - await parentSupervisor.Stop(); + // Stop parent + await parentSupervisor.Stop(); - // Wait for child to stop - await childHasStopped.TimeoutAfter(TimeSpan.FromSeconds(2)); - } + // Wait for child to stop + await childHasStopped.TimeoutAfter(TimeSpan.FromSeconds(2)); } } \ No newline at end of file diff --git a/src/LittleForker.Tests/ProcessSupervisorTests.cs b/src/LittleForker.Tests/ProcessSupervisorTests.cs index 3e08af7..105ee92 100644 --- a/src/LittleForker.Tests/ProcessSupervisorTests.cs +++ b/src/LittleForker.Tests/ProcessSupervisorTests.cs @@ -6,220 +6,172 @@ using Xunit; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class ProcessSupervisorTests { - public class ProcessSupervisorTests : IDisposable - { - private readonly ITestOutputHelper _outputHelper; - private readonly ILoggerFactory _loggerFactory; + private readonly ITestOutputHelper _outputHelper; + private readonly ILoggerFactory _loggerFactory; - public ProcessSupervisorTests(ITestOutputHelper outputHelper) - { - _outputHelper = outputHelper; - _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; - } + public ProcessSupervisorTests(ITestOutputHelper outputHelper) + { + _outputHelper = outputHelper; + _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; + } - [Fact] - public async Task Given_invalid_process_path_then_state_should_be_StartError() - { - var supervisor = new ProcessSupervisor(_loggerFactory, ProcessRunType.NonTerminating, "c:/", "invalid.exe"); - var stateIsStartFailed = supervisor.WhenStateIs(ProcessSupervisor.State.StartFailed); - await supervisor.Start(); + [Fact] + public async Task Given_invalid_process_path_then_state_should_be_StartError() + { + var settings = new ProcessSupervisorSettings("c:/", "invalid.exe"); + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + var stateIsStartFailed = supervisor.WhenStateIs(ProcessSupervisor.State.StartFailed); + await supervisor.Start(); - await stateIsStartFailed; - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); + await stateIsStartFailed; + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); + supervisor.OnStartException.ShouldNotBeNull(); - _outputHelper.WriteLine(supervisor.OnStartException.ToString()); - } + _outputHelper.WriteLine(supervisor.OnStartException.ToString()); + } - [Fact] - public async Task Given_invalid_working_directory_then_state_should_be_StartError() - { - var supervisor = new ProcessSupervisor(_loggerFactory, ProcessRunType.NonTerminating, "c:/does_not_exist", "git.exe"); - await supervisor.Start(); + [Fact] + public async Task Given_invalid_working_directory_then_state_should_be_StartError() + { + var settings = new ProcessSupervisorSettings("c:/does_not_exist", "git.exe"); + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + await supervisor.Start(); - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); + supervisor.OnStartException.ShouldNotBeNull(); - _outputHelper.WriteLine(supervisor.OnStartException.ToString()); - } + _outputHelper.WriteLine(supervisor.OnStartException.ToString()); + } - [Fact] - public async Task Given_short_running_exe_then_should_run_to_exit() + [Fact] + public async Task Given_short_running_exe_then_should_run_to_exit() + { + var envVars = new StringDictionary {{"a", "b"}}; + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var envVars = new StringDictionary {{"a", "b"}}; - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.SelfTerminating, - Environment.CurrentDirectory, - "dotnet", - "./SelfTerminatingProcess/SelfTerminatingProcess.dll", - envVars); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var whenStateIsExited = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); - var whenStateIsExitedWithError = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedWithError); - - await supervisor.Start(); - - var task = await Task.WhenAny(whenStateIsExited, whenStateIsExitedWithError); - - task.ShouldBe(whenStateIsExited); - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.ExitedSuccessfully); - supervisor.OnStartException.ShouldBeNull(); - supervisor.ProcessInfo.ExitCode.ShouldBe(0); - } - - [Fact] - public async Task Given_non_terminating_process_then_should_exit_when_stopped() + Arguments = "./SelfTerminatingProcess/SelfTerminatingProcess.dll", + EnvironmentVariables = envVars + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); + var whenStateIsExited = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); + var whenStateIsExitedWithError = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedWithError); + + await supervisor.Start(); + + var task = await Task.WhenAny(whenStateIsExited, whenStateIsExitedWithError); + + task.ShouldBe(whenStateIsExited); + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.ExitedSuccessfully); + supervisor.OnStartException.ShouldBeNull(); + supervisor.ProcessInfo.ExitCode.ShouldBe(0); + } + + [Fact] + public async Task Given_non_terminating_process_then_should_exit_when_stopped() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2($"Process: {data}"); - var running = supervisor.WhenStateIs(ProcessSupervisor.State.Running); - await supervisor.Start(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.Running); - await running; - - await supervisor.Stop(TimeSpan.FromSeconds(5)); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.ExitedSuccessfully); - supervisor.OnStartException.ShouldBeNull(); - supervisor.ProcessInfo.ExitCode.ShouldBe(0); - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2($"Process: {data}"); + var running = supervisor.WhenStateIs(ProcessSupervisor.State.Running); + await supervisor.Start(); + + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.Running); + await running; + + await supervisor.Stop(TimeSpan.FromSeconds(5)); + + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.ExitedSuccessfully); + supervisor.OnStartException.ShouldBeNull(); + supervisor.ProcessInfo.ExitCode.ShouldBe(0); + } - [Fact] - public async Task Can_restart_a_stopped_short_running_process() - { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.SelfTerminating, - Environment.CurrentDirectory, - "dotnet", - "./SelfTerminatingProcess/SelfTerminatingProcess.dll"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var stateIsStopped = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); - await supervisor.Start(); - await stateIsStopped; - - await supervisor.Start(); - await stateIsStopped; - } - - [Fact] - public async Task Can_restart_a_stopped_long_running_process() + [Fact] + public async Task Can_restart_a_stopped_long_running_process() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var exitedKilled = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); - await supervisor.Start(); - await supervisor.Stop(); - await exitedKilled.TimeoutAfter(TimeSpan.FromSeconds(5)); - - // Restart - var exitedSuccessfully = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); - await supervisor.Start(); - await supervisor.Stop(TimeSpan.FromSeconds(2)); - await exitedSuccessfully; - } - - [Fact] - public async Task When_stop_a_non_terminating_process_without_a_timeout_then_should_exit_killed() + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); + var exitedKilled = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); + await supervisor.Start(); + await supervisor.Stop(); + await exitedKilled.TimeoutAfter(TimeSpan.FromSeconds(5)); + + // Restart + var exitedSuccessfully = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); + await supervisor.Start(); + await supervisor.Stop(TimeSpan.FromSeconds(2)); + await exitedSuccessfully; + } + + [Fact] + public async Task When_stop_a_non_terminating_process_without_a_timeout_then_should_exit_killed() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var stateIsStopped = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); - await supervisor.Start(); - await supervisor.Stop(); // No timeout so will just kill the process - await stateIsStopped.TimeoutAfter(TimeSpan.FromSeconds(2)); - - _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); + var stateIsStopped = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); + await supervisor.Start(); + await supervisor.Stop(); // No timeout so will just kill the process + await stateIsStopped.TimeoutAfter(TimeSpan.FromSeconds(2)); + + _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); + } - [Fact] - public async Task When_stop_a_non_terminating_process_that_does_not_shutdown_within_timeout_then_should_exit_killed() - { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll --ignore-shutdown-signal=true"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var stateIsKilled = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); - await supervisor.Start(); - await supervisor.Stop(TimeSpan.FromSeconds(2)); - await stateIsKilled.TimeoutAfter(TimeSpan.FromSeconds(5)); - - _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); - } - - [Fact] - public async Task When_stop_a_non_terminating_process_with_non_zero_then_should_exit_error() - { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll --exit-with-non-zero=true"); - supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); - var stateExitWithError = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedWithError); - await supervisor.Start(); - await supervisor.Stop(TimeSpan.FromSeconds(5)); - await stateExitWithError.TimeoutAfter(TimeSpan.FromSeconds(5)); - supervisor.ProcessInfo.ExitCode.ShouldNotBe(0); - - _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); - } - - [Fact] - public async Task Can_attempt_to_restart_a_failed_short_running_process() + [Fact] + public async Task When_stop_a_non_terminating_process_that_does_not_shutdown_within_timeout_then_should_exit_killed() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "invalid.exe"); - await supervisor.Start(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - - await supervisor.Start(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll --ignore-shutdown-signal=true" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); + var stateIsKilled = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedKilled); + await supervisor.Start(); + await supervisor.Stop(TimeSpan.FromSeconds(2)); + await stateIsKilled.TimeoutAfter(TimeSpan.FromSeconds(5)); + + _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); + } - [Fact] - public void WriteDotGraph() - { - var processController = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "invalid.exe"); - _outputHelper.WriteLine(processController.GetDotGraph()); - } - - public void Dispose() + [Fact] + public async Task When_stop_a_non_terminating_process_with_non_zero_then_should_exit_error() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { - } + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll --exit-with-non-zero=true" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); + supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); + var stateExitWithError = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedWithError); + await supervisor.Start(); + await supervisor.Stop(TimeSpan.FromSeconds(5)); + await stateExitWithError.TimeoutAfter(TimeSpan.FromSeconds(5)); + supervisor.ProcessInfo.ExitCode.ShouldNotBe(0); + + _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); + } + + [Fact] + public void WriteDotGraph() + { + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, + "invalid.exe"); + var processController = new ProcessSupervisor(settings, _loggerFactory); + _outputHelper.WriteLine(processController.GetDotGraph()); } -} +} \ No newline at end of file diff --git a/src/LittleForker.Tests/XunitLoggerFactory.cs b/src/LittleForker.Tests/XunitLoggerFactory.cs index ee1f5f3..ca9dbd8 100644 --- a/src/LittleForker.Tests/XunitLoggerFactory.cs +++ b/src/LittleForker.Tests/XunitLoggerFactory.cs @@ -2,18 +2,17 @@ using Microsoft.Extensions.Logging; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class XunitLoggerFactory { - public class XunitLoggerFactory + public XunitLoggerFactory(ITestOutputHelper outputHelper) { - public XunitLoggerFactory(ITestOutputHelper outputHelper) - { - var serviceCollection = new ServiceCollection(); - serviceCollection.AddLogging(builder => builder.AddXUnit(outputHelper)); - var provider = serviceCollection.BuildServiceProvider(); - LoggerFactory = provider.GetRequiredService(); - } - - public ILoggerFactory LoggerFactory { get; } + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(builder => builder.AddXUnit(outputHelper)); + var provider = serviceCollection.BuildServiceProvider(); + LoggerFactory = provider.GetRequiredService(); } + + public ILoggerFactory LoggerFactory { get; } } \ No newline at end of file diff --git a/src/LittleForker/CooperativeShutdown.cs b/src/LittleForker/CooperativeShutdown.cs index 7c625e9..d433df0 100644 --- a/src/LittleForker/CooperativeShutdown.cs +++ b/src/LittleForker/CooperativeShutdown.cs @@ -6,186 +6,190 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; -namespace LittleForker +namespace LittleForker; + +/// +/// Allows a process to be co-cooperatively shut down (as opposed the more +/// brutal Process.Kill() +/// +public static class CooperativeShutdown { /// - /// Allows a process to be co-cooperatively shut down (as opposed the more - /// brutal Process.Kill() + /// The pipe name a process will listen on for a EXIT signal. + /// + /// The process ID process listening. + /// A generated pipe name. + public static string GetPipeName(int processId) => $"LittleForker-{processId}"; + + /// + /// Creates a listener for cooperative shutdown. /// - public static class CooperativeShutdown + /// + /// The callback that is invoked when cooperative shutdown has been + /// requested. + /// + /// + /// A logger factory. + /// + /// A method to be called if an error occurs while listening + /// + /// A disposable representing the named pipe listener. + /// + public static Task Listen(Action shutdownRequested, ILoggerFactory loggerFactory, Action onError = default) { - /// - /// The pipe name a process will listen on for a EXIT signal. - /// - /// The process ID process listening. - /// A generated pipe name. - public static string GetPipeName(int processId) => $"LittleForker-{processId}"; - - /// - /// Creates a listener for cooperative shutdown. - /// - /// - /// The callback that is invoked when cooperative shutdown has been - /// requested. - /// - /// - /// A logger factory. - /// - /// A method to be called if an error occurs while listening - /// - /// A disposable representing the named pipe listener. - /// - public static Task Listen(Action shutdownRequested, ILoggerFactory loggerFactory, Action onError = default) - { - var listener = new CooperativeShutdownListener( - GetPipeName(Process.GetCurrentProcess().Id), - shutdownRequested, - loggerFactory.CreateLogger($"{nameof(LittleForker)}.{typeof(CooperativeShutdown).Name}")); + var listener = new CooperativeShutdownListener( + GetPipeName(Process.GetCurrentProcess().Id), + shutdownRequested, + loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}")); - Task.Run(async () => + Task.Run(async () => + { + try { - try - { - await listener.Listen().ConfigureAwait(false); - } - catch (Exception ex) - { - onError?.Invoke(ex); - } - }); + await listener.Listen().ConfigureAwait(false); + } + catch (Exception ex) + { + onError?.Invoke(ex); + } + }); - return Task.FromResult((IDisposable)listener); - } + return Task.FromResult((IDisposable)listener); + } - /// - /// Signals to a process to shut down. - /// - /// The process ID to signal too. - /// A logger factory. - /// A task representing the operation. - // TODO Should exceptions rethrow or should we let the caller that the signalling failed i.e. Task? - public static async Task SignalExit(int processId, ILoggerFactory loggerFactory) + /// + /// Signals to a process to shut down. + /// + /// The process ID to signal too. + /// A logger factory. + /// A task representing the operation. + // TODO Should exceptions rethrow or should we let the caller that the signalling failed i.e. Task? + public static Task SignalExit(int processId, ILoggerFactory loggerFactory) + { + var pipeName = GetPipeName(processId); + return SignalExit(pipeName, loggerFactory); + } + + public static async Task SignalExit(string pipeName, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}"); + using (var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) { - var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}"); - var pipeName = GetPipeName(processId); - using (var pipe = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous)) + try { - try - { - await pipe.ConnectAsync((int)TimeSpan.FromSeconds(3).TotalMilliseconds).ConfigureAwait(false); - var streamWriter = new StreamWriter(pipe); - var streamReader = new StreamReader(pipe, true); - logger.LogInformation($"Signalling EXIT to client on pipe {pipeName}..."); - await SignalExit(streamWriter, streamReader).TimeoutAfter(TimeSpan.FromSeconds(3)).ConfigureAwait(false); - logger.LogInformation($"Signalling EXIT to client on pipe {pipeName} successful."); - } - catch (IOException ex) - { - logger.LogError(ex, $"Failed to signal EXIT to client on pipe {pipeName}."); - } - catch (TimeoutException ex) - { - logger.LogError(ex, $"Timeout signalling EXIT on pipe {pipeName}."); - } - catch (Exception ex) - { - logger.LogError(ex, $"Failed to signal EXIT to client on pipe {pipeName}."); - } + await pipe.ConnectAsync((int)TimeSpan.FromSeconds(3).TotalMilliseconds).ConfigureAwait(false); + var streamWriter = new StreamWriter(pipe); + var streamReader = new StreamReader(pipe, true); + logger.LogInformation($"Signalling EXIT to client on pipe {pipeName}..."); + await SignalExit(streamWriter, streamReader).TimeoutAfter(TimeSpan.FromSeconds(3)).ConfigureAwait(false); + logger.LogInformation($"Signalling EXIT to client on pipe {pipeName} successful."); + } + catch (IOException ex) + { + logger.LogError(ex, $"Failed to signal EXIT to client on pipe {pipeName}."); + } + catch (TimeoutException ex) + { + logger.LogError(ex, $"Timeout signalling EXIT on pipe {pipeName}."); + } + catch (Exception ex) + { + logger.LogError(ex, $"Failed to signal EXIT to client on pipe {pipeName}."); } } + } - private static async Task SignalExit(TextWriter streamWriter, TextReader streamReader) + private static async Task SignalExit(TextWriter streamWriter, TextReader streamReader) + { + await streamWriter.WriteLineAsync("EXIT").ConfigureAwait(false); + await streamWriter.FlushAsync().ConfigureAwait(false); + await streamReader.ReadLineAsync().TimeoutAfter(TimeSpan.FromSeconds(3)).ConfigureAwait(false); // Reads an 'OK'. + } + + private sealed class CooperativeShutdownListener : IDisposable + { + private readonly string _pipeName; + private readonly Action _shutdownRequested; + private readonly ILogger _logger; + private readonly CancellationTokenSource _stopListening; + + internal CooperativeShutdownListener( + string pipeName, + Action shutdownRequested, + ILogger logger) { - await streamWriter.WriteLineAsync("EXIT").ConfigureAwait(false); - await streamWriter.FlushAsync().ConfigureAwait(false); - await streamReader.ReadLineAsync().TimeoutAfter(TimeSpan.FromSeconds(3)).ConfigureAwait(false); // Reads an 'OK'. + _pipeName = pipeName; + _shutdownRequested = shutdownRequested; + _logger = logger; + _stopListening = new CancellationTokenSource(); } - private sealed class CooperativeShutdownListener : IDisposable + internal async Task Listen() { - private readonly string _pipeName; - private readonly Action _shutdownRequested; - private readonly ILogger _logger; - private readonly CancellationTokenSource _stopListening; - - internal CooperativeShutdownListener( - string pipeName, - Action shutdownRequested, - ILogger logger) + while (!_stopListening.IsCancellationRequested) { - _pipeName = pipeName; - _shutdownRequested = shutdownRequested; - _logger = logger; - _stopListening = new CancellationTokenSource(); - } - - internal async Task Listen() - { - while (!_stopListening.IsCancellationRequested) - { - // message transmission mode is not supported on Unix - var pipe = new NamedPipeServerStream(_pipeName, - PipeDirection.InOut, - NamedPipeServerStream.MaxAllowedServerInstances, - PipeTransmissionMode.Byte, - PipeOptions.None); + // message transmission mode is not supported on Unix + var pipe = new NamedPipeServerStream(_pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.None); - _logger.LogInformation($"Listening on pipe '{_pipeName}'."); + _logger.LogInformation($"Listening on pipe '{_pipeName}'."); - await pipe - .WaitForConnectionAsync(_stopListening.Token) - .ConfigureAwait(false); + await pipe + .WaitForConnectionAsync(_stopListening.Token) + .ConfigureAwait(false); - _logger.LogInformation($"Client connected to pipe '{_pipeName}'."); + _logger.LogInformation($"Client connected to pipe '{_pipeName}'."); - try + try + { + using (var reader = new StreamReader(pipe)) { - using (var reader = new StreamReader(pipe)) + using (var writer = new StreamWriter(pipe) { AutoFlush = true }) { - using (var writer = new StreamWriter(pipe) { AutoFlush = true }) + while (true) { - while (true) + // a pipe can get disconnected after OS pipes enumeration as well + if (!pipe.IsConnected) { - // a pipe can get disconnected after OS pipes enumeration as well - if (!pipe.IsConnected) - { - _logger.LogDebug($"Pipe {_pipeName} connection is broken re-connecting"); - break; - } + _logger.LogDebug($"Pipe {_pipeName} connection is broken re-connecting"); + break; + } - var s = await reader.ReadLineAsync().WithCancellation(_stopListening.Token) - .ConfigureAwait(false); + var s = await reader.ReadLineAsync().WithCancellation(_stopListening.Token) + .ConfigureAwait(false); - if (s != "EXIT") - { - continue; - } + if (s != "EXIT") + { + continue; + } - _logger.LogInformation($"Received command from server: {s}"); + _logger.LogInformation($"Received command from server: {s}"); - await writer.WriteLineAsync("OK").ConfigureAwait(false); - _logger.LogInformation("Responded with OK"); + await writer.WriteLineAsync("OK").ConfigureAwait(false); + _logger.LogInformation("Responded with OK"); - _logger.LogInformation("Raising exit request..."); - _shutdownRequested(); + _logger.LogInformation("Raising exit request..."); + _shutdownRequested(); - return; - } + return; } } } - catch (IOException ex) - { - // As the pipe connection should be restored this exception should not be considered as terminating - _logger.LogDebug(ex, "Pipe connection failed"); - } + } + catch (IOException ex) + { + // As the pipe connection should be restored this exception should not be considered as terminating + _logger.LogDebug(ex, "Pipe connection failed"); } } + } - public void Dispose() - { - _stopListening.Cancel(); - } + public void Dispose() + { + _stopListening.Cancel(); } } -} +} \ No newline at end of file diff --git a/src/LittleForker/CooperativeShutdownHostedService.cs b/src/LittleForker/CooperativeShutdownHostedService.cs new file mode 100644 index 0000000..c2e6b53 --- /dev/null +++ b/src/LittleForker/CooperativeShutdownHostedService.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Hosting; +using System.IO.Pipes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Threading; + +namespace LittleForker; + +public class CooperativeShutdownHostedService : BackgroundService +{ + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly ILogger _logger; + private readonly CooperativeShutdownHostedServiceOptions _options; + + public CooperativeShutdownHostedService( + IHostApplicationLifetime applicationLifetime, + IOptions options, + ILogger logger) + { + _applicationLifetime = applicationLifetime; + _logger = logger; + _options = options.Value; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (string.IsNullOrWhiteSpace(_options.PipeName)) + { + _logger.LogWarning("Pipe name not specified. Process will not be listening for " + + "cooperative shutdown requests."); + return; + } + while (!stoppingToken.IsCancellationRequested) + { + // message transmission mode is not supported on Unix + var pipe = new NamedPipeServerStream(_options.PipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.None); + + _logger.LogInformation("Listening on pipe '{pipeName}'.", _options.PipeName); + + await pipe + .WaitForConnectionAsync(stoppingToken) + .ConfigureAwait(false); + + _logger.LogInformation("Client connected to pipe '{pipeName}'.", _options.PipeName); + + try + { + using var reader = new StreamReader(pipe); + await using var writer = new StreamWriter(pipe) { AutoFlush = true }; + while (true) + { + // a pipe can get disconnected after OS pipes enumeration as well + if (!pipe.IsConnected) + { + _logger.LogDebug("Pipe {pipeName} connection is broken, re-connecting.", _options.PipeName); + break; + } + + var command = await reader + .ReadLineAsync() + .WaitAsync(stoppingToken) + .ConfigureAwait(false); + + if (command != "EXIT") + { + continue; + } + + _logger.LogInformation("Received command from server: {s}", command); + + await writer.WriteLineAsync("OK"); + _logger.LogInformation("Responded with OK"); + + _logger.LogInformation("Raising exit request..."); + _applicationLifetime.StopApplication(); + return; + } + } + catch (IOException ex) + { + // As the pipe connection should be restored this exception should not be considered as terminating + _logger.LogWarning(ex, "Pipe connection failed."); + } + } + _logger.LogInformation("Cooperative shutdown service is stopping."); + } +} \ No newline at end of file diff --git a/src/LittleForker/CooperativeShutdownHostedServiceOptions.cs b/src/LittleForker/CooperativeShutdownHostedServiceOptions.cs new file mode 100644 index 0000000..9456c79 --- /dev/null +++ b/src/LittleForker/CooperativeShutdownHostedServiceOptions.cs @@ -0,0 +1,6 @@ +namespace LittleForker; + +public class CooperativeShutdownHostedServiceOptions +{ + public string PipeName { get; set; } = null!; +} \ No newline at end of file diff --git a/src/LittleForker/IProcessInfo.cs b/src/LittleForker/IProcessInfo.cs index 4d3d5b2..469bd7c 100644 --- a/src/LittleForker/IProcessInfo.cs +++ b/src/LittleForker/IProcessInfo.cs @@ -1,15 +1,14 @@ -namespace LittleForker +namespace LittleForker; + +public interface IProcessInfo { - public interface IProcessInfo - { - /// - /// The process's exit code. - /// - int ExitCode { get; } + /// + /// The process's exit code. + /// + int ExitCode { get; } - /// - /// The process's Id. - /// - int Id { get; } - } + /// + /// The process's Id. + /// + int Id { get; } } \ No newline at end of file diff --git a/src/LittleForker/InterlockedBoolean.cs b/src/LittleForker/InterlockedBoolean.cs index abfe400..89bd7cd 100644 --- a/src/LittleForker/InterlockedBoolean.cs +++ b/src/LittleForker/InterlockedBoolean.cs @@ -19,51 +19,50 @@ using System.Threading; -namespace LittleForker +namespace LittleForker; + +/// +/// Interlocked support for boolean values +/// +internal class InterlockedBoolean { + private int _value; + /// - /// Interlocked support for boolean values + /// Current value /// - internal class InterlockedBoolean - { - private int _value; - - /// - /// Current value - /// - public bool Value => _value == 1; + public bool Value => _value == 1; - /// - /// Initializes a new instance of - /// - /// initial value - public InterlockedBoolean(bool initialValue = false) - { - _value = initialValue ? 1 : 0; - } + /// + /// Initializes a new instance of + /// + /// initial value + public InterlockedBoolean(bool initialValue = false) + { + _value = initialValue ? 1 : 0; + } - /// - /// Sets a new value - /// - /// new value - /// the original value before any operation was performed - public bool Set(bool newValue) - { - var oldValue = Interlocked.Exchange(ref _value, newValue ? 1 : 0); - return oldValue == 1; - } + /// + /// Sets a new value + /// + /// new value + /// the original value before any operation was performed + public bool Set(bool newValue) + { + var oldValue = Interlocked.Exchange(ref _value, newValue ? 1 : 0); + return oldValue == 1; + } - /// - /// Compares the current value and the comparand for equality and, if they are equal, - /// replaces the current value with the new value in an atomic/thread-safe operation. - /// - /// new value - /// value to compare the current value with - /// the original value before any operation was performed - public bool CompareExchange(bool newValue, bool comparand) - { - var oldValue = Interlocked.CompareExchange(ref _value, newValue ? 1 : 0, comparand ? 1 : 0); - return oldValue == 1; - } + /// + /// Compares the current value and the comparand for equality and, if they are equal, + /// replaces the current value with the new value in an atomic/thread-safe operation. + /// + /// new value + /// value to compare the current value with + /// the original value before any operation was performed + public bool CompareExchange(bool newValue, bool comparand) + { + var oldValue = Interlocked.CompareExchange(ref _value, newValue ? 1 : 0, comparand ? 1 : 0); + return oldValue == 1; } } \ No newline at end of file diff --git a/src/LittleForker/LittleForker.csproj b/src/LittleForker/LittleForker.csproj index 10b2ff4..1125101 100644 --- a/src/LittleForker/LittleForker.csproj +++ b/src/LittleForker/LittleForker.csproj @@ -12,10 +12,17 @@ true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + true + enable + + + + + all diff --git a/src/LittleForker/ProcessExitedHelper.cs b/src/LittleForker/ProcessExitedHelper.cs index be6929d..eb34e1d 100644 --- a/src/LittleForker/ProcessExitedHelper.cs +++ b/src/LittleForker/ProcessExitedHelper.cs @@ -1,88 +1,85 @@ using System; using System.Diagnostics; -using System.Linq; -using System.Threading; using Microsoft.Extensions.Logging; -namespace LittleForker +namespace LittleForker; + +/// +/// Helper that raises event when the process has exited. A wrapper around +/// Process.Exited with some error handling and logging. +/// +public sealed class ProcessExitedHelper : IDisposable { + private int _processExitedRaised; + private readonly Process? _process; + /// - /// Helper that raises event when the process has exited. A wrapper around - /// Process.Exited with some error handling and logging. + /// Initializes a new instance of /// - public sealed class ProcessExitedHelper : IDisposable + /// + /// The process Id of the process to watch for exited. + /// + /// + /// A callback that is invoked when process has exited or does not + /// exist with the instance as a + /// parameter. + /// + /// + /// A logger. + /// + public ProcessExitedHelper( + int processId, + Action processExited, + ILoggerFactory loggerFactory) { - private int _processExitedRaised; - private readonly Process _process; + ProcessId = processId; + var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessExitedHelper)}"); - /// - /// Initializes a new instance of - /// - /// - /// The process Id of the process to watch for exited. - /// - /// - /// A callback that is invoked when process has exited or does not - /// exist with the instance as a - /// parameter. - /// - /// - /// A logger. - /// - public ProcessExitedHelper( - int processId, - Action processExited, - ILoggerFactory loggerFactory) + _process = Process.GetProcesses().SingleOrDefault(pr => pr.Id == processId); + if (_process == null) { - ProcessId = processId; - var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessExitedHelper)}"); - - _process = Process.GetProcesses().SingleOrDefault(pr => pr.Id == processId); - if (_process == null) - { - logger.LogError($"Process with Id {processId} was not found."); - OnProcessExit(); - return; - } - logger.LogInformation($"Process with Id {processId} found."); - try - { - _process.EnableRaisingEvents = true; - _process.Exited += (_, __) => - { - logger.LogInformation($"Parent process with Id {processId} exited."); - OnProcessExit(); - }; - } - // Race condition: this may be thrown if the process has already exited before - // attaching to the Exited event - catch (InvalidOperationException ex) + logger.LogError($"Process with Id {processId} was not found."); + OnProcessExit(); + return; + } + logger.LogInformation($"Process with Id {processId} found."); + try + { + _process.EnableRaisingEvents = true; + _process.Exited += (_, __) => { - logger.LogInformation($"Process with Id {processId} has already exited.", ex); + logger.LogInformation($"Parent process with Id {processId} exited."); OnProcessExit(); - } + }; + } + // Race condition: this may be thrown if the process has already exited before + // attaching to the Exited event + catch (InvalidOperationException ex) + { + logger.LogInformation($"Process with Id {processId} has already exited.", ex); + OnProcessExit(); + } - if (_process.HasExited) - { - logger.LogInformation($"Process with Id {processId} has already exited."); - OnProcessExit(); - } + if (_process.HasExited) + { + logger.LogInformation($"Process with Id {processId} has already exited."); + OnProcessExit(); + } - void OnProcessExit() + void OnProcessExit() + { + if (Interlocked.CompareExchange(ref _processExitedRaised, 1, 0) == 0) // Ensure raised once { - if (Interlocked.CompareExchange(ref _processExitedRaised, 1, 0) == 0) // Ensure raised once - { - logger.LogInformation("Raising process exited."); - processExited(this); - } + logger.LogInformation("Raising process exited."); + processExited(this); } } + } - public int ProcessId { get; } + public int ProcessId { get; } - public void Dispose() - { - _process?.Dispose(); - } + public void Dispose() + { + _process?.Dispose(); } -} +} \ No newline at end of file diff --git a/src/LittleForker/ProcessInfo.cs b/src/LittleForker/ProcessInfo.cs index 155e2d8..f977f9d 100644 --- a/src/LittleForker/ProcessInfo.cs +++ b/src/LittleForker/ProcessInfo.cs @@ -1,18 +1,17 @@ using System.Diagnostics; -namespace LittleForker +namespace LittleForker; + +internal class ProcessInfo : IProcessInfo { - internal class ProcessInfo : IProcessInfo - { - private readonly Process _process; + private readonly Process _process; - internal ProcessInfo(Process process) - { - _process = process; - } + internal ProcessInfo(Process process) + { + _process = process; + } - public int ExitCode => _process.ExitCode; + public int ExitCode => _process.ExitCode; - public int Id => _process.Id; - } + public int Id => _process.Id; } \ No newline at end of file diff --git a/src/LittleForker/ProcessRunType.cs b/src/LittleForker/ProcessRunType.cs deleted file mode 100644 index fec2fb1..0000000 --- a/src/LittleForker/ProcessRunType.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace LittleForker -{ - /// - /// Defined how a process is expected to run. - /// - public enum ProcessRunType - { - /// - /// Processes that are expected to terminate of their own accord. - /// - SelfTerminating, - /// - /// Processes that are not expected to terminiate of their own - /// accord and that must be co-operatively shutdown or killed. - /// - NonTerminating - } -} \ No newline at end of file diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index b97ba2d..6d9cf7d 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -1,311 +1,328 @@ -using System; -using System.Collections.Specialized; +using System.Collections.Specialized; using System.Diagnostics; -using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Stateless; using Stateless.Graph; -namespace LittleForker +namespace LittleForker; + +/// +/// Launches an process and tracks it's lifecycle . +/// +public class ProcessSupervisor : IDisposable { + private readonly ILogger _logger; + private readonly string _arguments; + private readonly StringDictionary _environmentVariables; + private readonly bool _captureStdErr; + private readonly string _processPath; + private readonly StateMachine.TriggerWithParameters _startErrorTrigger; + private readonly StateMachine.TriggerWithParameters _stopTrigger; + private readonly StateMachine _processStateMachine = new(State.NotStarted, FiringMode.Immediate); + private readonly string _workingDirectory; + private Process _process; + private readonly ProcessSupervisorSettings _settings; + private readonly ILoggerFactory _loggerFactory; + private bool _killed; + private readonly TaskQueue _taskQueue = new(); + /// - /// Launches an process and tracks it's lifecycle . + /// The state a process is in. /// - public class ProcessSupervisor : IDisposable + public enum State { - private readonly ILogger _logger; - private readonly string _arguments; - private readonly StringDictionary _environmentVariables; - private readonly bool _captureStdErr; - private readonly string _processPath; - private readonly StateMachine.TriggerWithParameters _startErrorTrigger; - private readonly StateMachine.TriggerWithParameters _stopTrigger; - private readonly StateMachine _processStateMachine - = new StateMachine(State.NotStarted, FiringMode.Immediate); - private readonly string _workingDirectory; - private Process _process; - private readonly ILoggerFactory _loggerFactory; - private bool _killed; - private readonly TaskQueue _taskQueue = new TaskQueue(); - - /// - /// The state a process is in. - /// - public enum State - { - NotStarted, - Running, - StartFailed, - Stopping, - ExitedSuccessfully, - ExitedWithError, - ExitedUnexpectedly, - ExitedKilled - } + NotStarted, + Running, + StartFailed, + Stopping, + ExitedSuccessfully, + ExitedWithError, + ExitedUnexpectedly, + ExitedKilled + } - private enum Trigger - { - Start, - StartError, - Stop, - ProcessExit - } + private enum Trigger + { + Start, + StartError, + Stop, + ProcessExit + } - /// - /// Initializes a new instance of - /// - /// - /// The working directory to start the process in. - /// - /// - /// The path to the process. - /// - /// - /// The process run type. - /// - /// - /// A logger factory. - /// - /// - /// Arguments to be passed to the process. - /// - /// - /// Environment variables that are set before the process starts. - /// - /// - /// A flag to indicated whether to capture standard error output. - /// - public ProcessSupervisor( - ILoggerFactory loggerFactory, - ProcessRunType processRunType, - string workingDirectory, - string processPath, - string arguments = null, - StringDictionary environmentVariables = null, - bool captureStdErr = false) + /// + /// Initializes a new instance of + /// + /// + /// The working directory to start the process in. + /// + /// + /// The path to the process. + /// + /// + /// The process run type. + /// + /// + /// + /// A logger factory. + /// + /// + /// Arguments to be passed to the process. + /// + /// + /// Environment variables that are set before the process starts. + /// + /// + /// A flag to indicated whether to capture standard error output. + /// + public ProcessSupervisor( + ProcessSupervisorSettings settings, + ILoggerFactory loggerFactory) + { + _settings = settings; + _loggerFactory = loggerFactory; + + _logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessSupervisor)}-{settings.ProcessPath}"); + + _processStateMachine + .Configure(State.NotStarted) + .Permit(Trigger.Start, State.Running); + + _startErrorTrigger = _processStateMachine.SetTriggerParameters(Trigger.StartError); + _stopTrigger = _processStateMachine.SetTriggerParameters(Trigger.Stop); + + _processStateMachine + .Configure(State.Running) + .OnEntryFrom(Trigger.Start, OnStart) + .PermitIf( + Trigger.ProcessExit, + State.ExitedUnexpectedly, + () => _process.HasExited, + "NonTerminating but exited.") + .Permit(Trigger.Stop, State.Stopping) + .Permit(Trigger.StartError, State.StartFailed); + + _processStateMachine + .Configure(State.StartFailed) + .OnEntryFrom(_startErrorTrigger, OnStartError); + + _processStateMachine + .Configure(State.Stopping) + .OnEntryFromAsync(_stopTrigger, OnStop) + .PermitIf(Trigger.ProcessExit, State.ExitedSuccessfully, + () => !_killed + && _process.HasExited + && _process.ExitCode == 0, + "NonTerminating and shut down cleanly") + .PermitIf(Trigger.ProcessExit, State.ExitedWithError, + () => !_killed + && _process.HasExited + && _process.ExitCode != 0, + "NonTerminating and shut down with non-zero exit code") + .PermitIf(Trigger.ProcessExit, State.ExitedKilled, + () => _killed + && _process.HasExited + && _process.ExitCode != 0, + "NonTerminating and killed."); + + _processStateMachine + .Configure(State.StartFailed) + .Permit(Trigger.Start, State.Running); + + _processStateMachine + .Configure(State.ExitedSuccessfully) + .Permit(Trigger.Start, State.Running); + + _processStateMachine + .Configure(State.ExitedUnexpectedly) + .Permit(Trigger.Start, State.Running); + + _processStateMachine + .Configure(State.ExitedKilled) + .Permit(Trigger.Start, State.Running); + + _processStateMachine.OnTransitioned(transition => { - _loggerFactory = loggerFactory; - _workingDirectory = workingDirectory; - _processPath = processPath; - _arguments = arguments ?? string.Empty; - _environmentVariables = environmentVariables; - _captureStdErr = captureStdErr; - - _logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessSupervisor)}-{processPath}"); - - _processStateMachine - .Configure(State.NotStarted) - .Permit(Trigger.Start, State.Running); - - _startErrorTrigger = _processStateMachine.SetTriggerParameters(Trigger.StartError); - _stopTrigger = _processStateMachine.SetTriggerParameters(Trigger.Stop); - - _processStateMachine - .Configure(State.Running) - .OnEntryFrom(Trigger.Start, OnStart) - .PermitIf( - Trigger.ProcessExit, - State.ExitedSuccessfully, - () => processRunType == ProcessRunType.SelfTerminating - && _process.HasExited - && _process.ExitCode == 0, - "SelfTerminating && ExitCode==0") - .PermitIf( - Trigger.ProcessExit, - State.ExitedWithError, - () => processRunType == ProcessRunType.SelfTerminating - && _process.HasExited - && _process.ExitCode != 0, - "SelfTerminating && ExitCode!=0") - .PermitIf( - Trigger.ProcessExit, - State.ExitedUnexpectedly, - () => processRunType == ProcessRunType.NonTerminating - && _process.HasExited, - "NonTerminating and died.") - .Permit(Trigger.Stop, State.Stopping) - .Permit(Trigger.StartError, State.StartFailed); - - _processStateMachine - .Configure(State.StartFailed) - .OnEntryFrom(_startErrorTrigger, OnStartError); - - _processStateMachine - .Configure(State.Stopping) - .OnEntryFromAsync(_stopTrigger, OnStop) - .PermitIf(Trigger.ProcessExit, State.ExitedSuccessfully, - () => processRunType == ProcessRunType.NonTerminating - && !_killed - && _process.HasExited - && _process.ExitCode == 0, - "NonTerminating and shut down cleanly") - .PermitIf(Trigger.ProcessExit, State.ExitedWithError, - () => processRunType == ProcessRunType.NonTerminating - && !_killed - && _process.HasExited - && _process.ExitCode != 0, - "NonTerminating and shut down with non-zero exit code") - .PermitIf(Trigger.ProcessExit, State.ExitedKilled, - () => processRunType == ProcessRunType.NonTerminating - && _killed - && _process.HasExited - && _process.ExitCode != 0, - "NonTerminating and killed."); - - _processStateMachine - .Configure(State.StartFailed) - .Permit(Trigger.Start, State.Running); - - _processStateMachine - .Configure(State.ExitedSuccessfully) - .Permit(Trigger.Start, State.Running); - - _processStateMachine - .Configure(State.ExitedUnexpectedly) - .Permit(Trigger.Start, State.Running); - - _processStateMachine - .Configure(State.ExitedKilled) - .Permit(Trigger.Start, State.Running); - - _processStateMachine.OnTransitioned(transition => - { - _logger.LogInformation($"State transition from {transition.Source} to {transition.Destination}"); - StateChanged?.Invoke(transition.Destination); - }); - } + _logger.LogInformation($"State transition from {transition.Source} to {transition.Destination}"); + StateChanged?.Invoke(transition.Destination); + }); + } - /// - /// Contains the caught exception in the event a process failed to - /// be launched. - /// - public Exception OnStartException { get; private set; } - - /// - /// Information about the launched process. - /// - public IProcessInfo ProcessInfo { get; private set; } - - public State CurrentState => _processStateMachine.State; - - /// - /// Raised when the process emits console data. - /// - public event Action OutputDataReceived; - - /// - /// Raised when the process emits stderr console data. - /// - public event Action ErrorDataReceived; - - /// - /// Raised when the process state has changed. - /// - public event Action StateChanged; - - public string GetDotGraph() => UmlDotGraph.Format(_processStateMachine.GetInfo()); - - /// - /// Starts the process. - /// - public Task Start() - => _taskQueue.Enqueue(() => - { - _killed = false; - _processStateMachine.Fire(Trigger.Start); - }); - - /// - /// Initiates a process stop. If a timeout is supplied (and greater - /// than 0ms), it will attempt a "co-operative" shutdown by - /// signalling an EXIT command to the process. The process needs to - /// support such signalling and needs to complete within the timeout - /// otherwise the process will be terminated via Kill(). The maximum - /// recommended timeout is 25 seconds. This is 5 seconds less than - /// default 30 seconds that windows will consider a service to be - /// 'hung'. - /// - /// - /// - public async Task Stop(TimeSpan? timeout = null) + /// + /// Contains the caught exception in the event a process failed to + /// be launched. + /// + public Exception OnStartException { get; private set; } + + /// + /// Information about the launched process. + /// + public IProcessInfo? ProcessInfo { get; private set; } + + public State CurrentState => _processStateMachine.State; + + /// + /// Raised when the process emits console data. + /// + public event Action OutputDataReceived; + + /// + /// Raised when the process emits stderr console data. + /// + public event Action ErrorDataReceived; + + /// + /// Raised when the process state has changed. + /// + public event Action StateChanged; + + public string GetDotGraph() => UmlDotGraph.Format(_processStateMachine.GetInfo()); + + /// + /// Starts the process. + /// + public Task Start() + => _taskQueue.Enqueue(() => { - await await _taskQueue - .Enqueue(() => _processStateMachine.FireAsync(_stopTrigger, timeout)) - .ConfigureAwait(false); - } + _killed = false; + _processStateMachine.Fire(Trigger.Start); + }); - private void OnStart() + /// + /// Initiates a process stop. If a timeout is supplied (and greater + /// than 0ms), it will attempt a "co-operative" shutdown by + /// signalling an EXIT command to the process. The process needs to + /// support such signalling and needs to complete within the timeout + /// otherwise the process will be terminated via Kill(). The maximum + /// recommended timeout is 25 seconds. This is 5 seconds less than + /// default 30 seconds that windows will consider a service to be + /// 'hung'. + /// + /// + /// + public async Task Stop(TimeSpan? timeout = null) + { + await await _taskQueue + .Enqueue(() => _processStateMachine.FireAsync(_stopTrigger, timeout)) + .ConfigureAwait(false); + } + + private void OnStart() + { + OnStartException = null; + try { - OnStartException = null; - try + var processStartInfo = new ProcessStartInfo(_processPath) { - var processStartInfo = new ProcessStartInfo(_processPath) - { - Arguments = _arguments, - RedirectStandardOutput = true, - RedirectStandardError = _captureStdErr, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = _workingDirectory - }; - - // Copy over environment variables - if (_environmentVariables != null) + Arguments = _arguments, + RedirectStandardOutput = true, + RedirectStandardError = _captureStdErr, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = _workingDirectory + }; + + // Copy over environment variables + if (_environmentVariables != null) + { + foreach (string key in _environmentVariables.Keys) { - foreach (string key in _environmentVariables.Keys) - { - processStartInfo.EnvironmentVariables[key] = _environmentVariables[key]; - } + processStartInfo.EnvironmentVariables[key] = _environmentVariables[key]; } + } - // Start the process and capture it's output. - _process = new Process - { - StartInfo = processStartInfo, - EnableRaisingEvents = true - }; - _process.OutputDataReceived += (_, args) => OutputDataReceived?.Invoke(args.Data); - if (_captureStdErr) - { - _process.ErrorDataReceived += (_, args) => ErrorDataReceived?.Invoke(args.Data); - } - _process.Exited += (sender, args) => - { - _taskQueue.Enqueue(() => - { - _processStateMachine.Fire(Trigger.ProcessExit); - }); - }; - _process.Start(); - _process.BeginOutputReadLine(); - if (_captureStdErr) - { - _process.BeginErrorReadLine(); - } - ProcessInfo = new ProcessInfo(_process); + // Start the process and capture it's output. + _process = new Process + { + StartInfo = processStartInfo, + EnableRaisingEvents = true + }; + _process.OutputDataReceived += (_, args) => OutputDataReceived?.Invoke(args.Data); + if (_captureStdErr) + { + _process.ErrorDataReceived += (_, args) => ErrorDataReceived?.Invoke(args.Data); } - catch (Exception ex) + _process.Exited += (sender, args) => { - _logger.LogError(ex, $"Failed to start process {_processPath}"); - _processStateMachine.Fire(_startErrorTrigger, ex); + _taskQueue.Enqueue(() => + { + _processStateMachine.Fire(Trigger.ProcessExit); + }); + }; + _process.Start(); + _process.BeginOutputReadLine(); + if (_captureStdErr) + { + _process.BeginErrorReadLine(); } + + ProcessInfo = new ProcessInfo(_process); } - - private void OnStartError(Exception ex) + catch (Exception ex) { - OnStartException = ex; - _process?.Dispose(); - ProcessInfo = null; + _logger.LogError(ex, $"Failed to start process {_processPath}"); + _processStateMachine.Fire(_startErrorTrigger, ex); } + } + + private void OnStartError(Exception ex) + { + OnStartException = ex; + _process?.Dispose(); + ProcessInfo = null; + } - private async Task OnStop(TimeSpan? timeout) + private async Task OnStop(TimeSpan? timeout) + { + if (!timeout.HasValue || timeout.Value <= TimeSpan.Zero) { - if (!timeout.HasValue || timeout.Value <= TimeSpan.Zero) + try { + _logger.LogInformation($"Killing process {_process.Id}"); + _killed = true; + _process.Kill(); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + $"Exception occurred attempting to kill process {_process.Id}. This may if the " + + "in the a race condition where process has already exited and an attempt to kill it."); + } + } + else + { + try + { + // Signal process to exit. If no response from process (busy or + // doesn't support signaling, then this will timeout. Note: a + // process acknowledging that it has received an EXIT signal + // only means the process has _started_ to shut down. + + var exited = this.WhenStateIs(State.ExitedSuccessfully); + var exitedWithError = this.WhenStateIs(State.ExitedWithError); + + await CooperativeShutdown + .SignalExit(ProcessInfo.Id, _loggerFactory).TimeoutAfter(timeout.Value) + .ConfigureAwait(false); + + await Task + .WhenAny(exited, exitedWithError) + .TimeoutAfter(timeout.Value) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + // Process doesn't support EXIT signal OR it didn't respond in + // time so Kill it. Note: still a race condition - the EXIT + // signal may have been received but just not acknowledged in + // time. try { - _logger.LogInformation($"Killing process {_process.Id}"); + _logger.LogWarning( + $"Timed out waiting to signal the process to exit or the " + + $"process {_process.ProcessName} ({_process.Id}) did not shutdown in " + + $"the given time ({timeout})"); _killed = true; _process.Kill(); } @@ -313,62 +330,17 @@ private async Task OnStop(TimeSpan? timeout) { _logger.LogWarning( ex, - $"Exception occurred attempting to kill process {_process.Id}. This may if the " + - "in the a race condition where process has already exited and an attempt to kill it."); - } - } - else - { - try - { - // Signal process to exit. If no response from process (busy or - // doesn't support signaling, then this will timeout. Note: a - // process acknowledging that it has received an EXIT signal - // only means the process has _started_ to shut down. - - var exited = this.WhenStateIs(State.ExitedSuccessfully); - var exitedWithError = this.WhenStateIs(State.ExitedWithError); - - await CooperativeShutdown - .SignalExit(ProcessInfo.Id, _loggerFactory).TimeoutAfter(timeout.Value) - .ConfigureAwait(false); - - await Task - .WhenAny(exited, exitedWithError) - .TimeoutAfter(timeout.Value) - .ConfigureAwait(false); - } - catch (TimeoutException) - { - // Process doesn't support EXIT signal OR it didn't respond in - // time so Kill it. Note: still a race condition - the EXIT - // signal may have been received but just not acknowledged in - // time. - try - { - _logger.LogWarning( - $"Timed out waiting to signal the process to exit or the " + - $"process {_process.ProcessName} ({_process.Id}) did not shutdown in " + - $"the given time ({timeout})"); - _killed = true; - _process.Kill(); - } - catch (Exception ex) - { - _logger.LogWarning( - ex, - $"Exception occurred attempting to kill process {_process.Id}. This may occur " + - "in the a race condition where the process has exited, a timeout waiting for the exit," + - "and the attempt to kill it."); - } + $"Exception occurred attempting to kill process {_process.Id}. This may occur " + + "in the a race condition where the process has exited, a timeout waiting for the exit," + + "and the attempt to kill it."); } } } + } - public void Dispose() - { - _process?.Dispose(); - _taskQueue?.Dispose(); - } + public void Dispose() + { + _process?.Dispose(); + _taskQueue?.Dispose(); } -} +} \ No newline at end of file diff --git a/src/LittleForker/ProcessSupervisorExtensions.cs b/src/LittleForker/ProcessSupervisorExtensions.cs index 7b08c63..9118330 100644 --- a/src/LittleForker/ProcessSupervisorExtensions.cs +++ b/src/LittleForker/ProcessSupervisorExtensions.cs @@ -1,51 +1,50 @@ using System.Threading; using System.Threading.Tasks; -namespace LittleForker +namespace LittleForker; + +public static class ProcessSupervisorExtensions { - public static class ProcessSupervisorExtensions + public static Task WhenStateIs( + this ProcessSupervisor processSupervisor, + ProcessSupervisor.State processState, + CancellationToken cancellationToken = default) { - public static Task WhenStateIs( - this ProcessSupervisor processSupervisor, - ProcessSupervisor.State processState, - CancellationToken cancellationToken = default) - { - var taskCompletionSource = new TaskCompletionSource(); - cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()); + var taskCompletionSource = new TaskCompletionSource(); + cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()); - void Handler(ProcessSupervisor.State state) + void Handler(ProcessSupervisor.State state) + { + if (processState == state) { - if (processState == state) - { - taskCompletionSource.SetResult(0); - processSupervisor.StateChanged -= Handler; - } + taskCompletionSource.SetResult(0); + processSupervisor.StateChanged -= Handler; } + } - processSupervisor.StateChanged += Handler; + processSupervisor.StateChanged += Handler; - return taskCompletionSource.Task; - } + return taskCompletionSource.Task; + } - public static Task WhenOutputStartsWith( - this ProcessSupervisor processSupervisor, - string startsWith, - CancellationToken cancellationToken = default) - { - var taskCompletionSource = new TaskCompletionSource(); - cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()); + public static Task WhenOutputStartsWith( + this ProcessSupervisor processSupervisor, + string startsWith, + CancellationToken cancellationToken = default) + { + var taskCompletionSource = new TaskCompletionSource(); + cancellationToken.Register(() => taskCompletionSource.TrySetCanceled()); - void Handler(string data) + void Handler(string data) + { + if (data != null && data.StartsWith(startsWith)) { - if (data != null && data.StartsWith(startsWith)) - { - taskCompletionSource.SetResult(0); - processSupervisor.OutputDataReceived -= Handler; - } + taskCompletionSource.SetResult(0); + processSupervisor.OutputDataReceived -= Handler; } - - processSupervisor.OutputDataReceived += Handler; - return taskCompletionSource.Task; } + + processSupervisor.OutputDataReceived += Handler; + return taskCompletionSource.Task; } } \ No newline at end of file diff --git a/src/LittleForker/ProcessSupervisorSettings.cs b/src/LittleForker/ProcessSupervisorSettings.cs new file mode 100644 index 0000000..3961c38 --- /dev/null +++ b/src/LittleForker/ProcessSupervisorSettings.cs @@ -0,0 +1,26 @@ +using System.Collections.Specialized; + +namespace LittleForker; + +public class ProcessSupervisorSettings +{ + public string WorkingDirectory { get; } + public string ProcessPath { get; } + public string Arguments { get; set; } = string.Empty; + public StringDictionary EnvironmentVariables { get; set; } = new(); + public bool CaptureStdErr { get; set; } = false; + + /// + /// Initializes a new instance of + /// + /// + /// + /// + public ProcessSupervisorSettings( + string workingDirectory, + string processPath) + { + WorkingDirectory = workingDirectory; + ProcessPath = processPath; + } +} \ No newline at end of file diff --git a/src/LittleForker/ServiceCollectionExtensions.cs b/src/LittleForker/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ab33486 --- /dev/null +++ b/src/LittleForker/ServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LittleForker; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddChildProcessHostedService( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddHostedService(); + services.Configure(configuration); + return services; + } + + public static IServiceCollection AddCooperativeShutdownHostedService( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddHostedService(); + services.Configure(configuration); + return services; + } +} \ No newline at end of file diff --git a/src/LittleForker/TaskExtensions.cs b/src/LittleForker/TaskExtensions.cs index 827c267..d517f5c 100644 --- a/src/LittleForker/TaskExtensions.cs +++ b/src/LittleForker/TaskExtensions.cs @@ -1,58 +1,49 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +namespace LittleForker; -namespace LittleForker +internal static class TaskExtensions { - internal static class TaskExtensions + // https://stackoverflow.com/a/28626769 + + internal static Task WithCancellation(this Task task, CancellationToken cancellationToken) { - // https://stackoverflow.com/a/28626769 + return task.IsCompleted // fast-path optimization + ? task + : task.ContinueWith( + completedTask => completedTask.GetAwaiter().GetResult(), + cancellationToken, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } - internal static Task WithCancellation(this Task task, CancellationToken cancellationToken) - { - return task.IsCompleted // fast-path optimization - ? task - : task.ContinueWith( - completedTask => completedTask.GetAwaiter().GetResult(), - cancellationToken, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } + internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(); + var completedTask = await Task + .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) + .ConfigureAwait(false); - internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + if (completedTask == task) { - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) - { - var completedTask = await Task - .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) - .ConfigureAwait(false); - - if (completedTask == task) - { - timeoutCancellationTokenSource.Cancel(); - await task.ConfigureAwait(false); - return; - } - - throw new TimeoutException("The operation has timed out."); - } + timeoutCancellationTokenSource.Cancel(); + await task.ConfigureAwait(false); + return; } - internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) - { - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) - { - var completedTask = await Task - .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) - .ConfigureAwait(false); - if (completedTask == task) - { - timeoutCancellationTokenSource.Cancel(); - return await task.ConfigureAwait(false); - } + throw new TimeoutException("The operation has timed out."); + } - throw new TimeoutException("The operation has timed out."); - } + internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + { + using var timeoutCancellationTokenSource = new CancellationTokenSource(); + var completedTask = await Task + .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) + .ConfigureAwait(false); + if (completedTask == task) + { + timeoutCancellationTokenSource.Cancel(); + return await task.ConfigureAwait(false); } + + throw new TimeoutException("The operation has timed out."); } } \ No newline at end of file diff --git a/src/LittleForker/TaskQueue.cs b/src/LittleForker/TaskQueue.cs index 760bbc6..912add6 100644 --- a/src/LittleForker/TaskQueue.cs +++ b/src/LittleForker/TaskQueue.cs @@ -3,130 +3,128 @@ using System.Threading; using System.Threading.Tasks; -namespace LittleForker +namespace LittleForker; + +/// +/// Represents a queue of tasks where a task is processed one at a time. When disposed +/// the outstanding tasks are cancelled. +/// +internal class TaskQueue : IDisposable { + private readonly ConcurrentQueue> _taskQueue = new(); + private readonly CancellationTokenSource _isDisposed = new(); + private readonly InterlockedBoolean _isProcessing = new(); + /// - /// Represents a queue of tasks where a task is processed one at a time. When disposed - /// the outstanding tasks are cancelled. + /// Enqueues a task for processing. /// - internal class TaskQueue : IDisposable + /// The operations to invoke. + /// A task representing the operation. Awaiting is optional. + public Task Enqueue(Action action) { - private readonly ConcurrentQueue> _taskQueue = new ConcurrentQueue>(); - private readonly CancellationTokenSource _isDisposed = new CancellationTokenSource(); - private readonly InterlockedBoolean _isProcessing = new InterlockedBoolean(); - - - /// - /// Enqueues a task for processing. - /// - /// The operations to invoke. - /// A task representing the operation. Awaiting is optional. - public Task Enqueue(Action action) + var task = Enqueue(_ => { - var task = Enqueue(_ => - { - action(); - return Task.CompletedTask; - }); - return task; - } + action(); + return Task.CompletedTask; + }); + return task; + } - /// - /// Enqueues a task for processing. - /// - /// The operations to invoke. - /// A task representing the operation. Awaiting is optional. - public Task Enqueue(Func function) + /// + /// Enqueues a task for processing. + /// + /// The operations to invoke. + /// A task representing the operation. Awaiting is optional. + public Task Enqueue(Func function) + { + var task = Enqueue(_ => { - var task = Enqueue(_ => - { - var result = function(); - return Task.FromResult(result); - }); - return task; - } + var result = function(); + return Task.FromResult(result); + }); + return task; + } - /// - /// Enqueues a task for processing. - /// - /// The operation to invoke that is co-operatively cancelable. - /// A task representing the operation. Awaiting is optional. - public Task Enqueue(Func function) + /// + /// Enqueues a task for processing. + /// + /// The operation to invoke that is cooperatively cancelable. + /// A task representing the operation. Awaiting is optional. + public Task Enqueue(Func function) + { + var task = Enqueue(async ct => { - var task = Enqueue(async ct => - { - await function(ct).ConfigureAwait(false); - return true; - }); - return task; - } + await function(ct).ConfigureAwait(false); + return true; + }); + return task; + } - /// - /// Enqueues a task for processing. - /// - /// The operation to invoke that is co-operatively cancelable. - /// A task representing the operation. Awaiting is optional. - public Task Enqueue(Func> function) + /// + /// Enqueues a task for processing. + /// + /// The operation to invoke that is cooperatively cancelable. + /// A task representing the operation. Awaiting is optional. + public Task Enqueue(Func> function) + { + return EnqueueInternal(_taskQueue, function); + } + + private Task EnqueueInternal( + ConcurrentQueue> taskQueue, + Func> function) + { + var tcs = new TaskCompletionSource(); + if (_isDisposed.IsCancellationRequested) { - return EnqueueInternal(_taskQueue, function); + tcs.SetCanceled(); + return tcs.Task; } - - private Task EnqueueInternal( - ConcurrentQueue> taskQueue, - Func> function) + taskQueue.Enqueue(async () => { - var tcs = new TaskCompletionSource(); if (_isDisposed.IsCancellationRequested) { tcs.SetCanceled(); - return tcs.Task; + return; } - taskQueue.Enqueue(async () => + try { - if (_isDisposed.IsCancellationRequested) - { - tcs.SetCanceled(); - return; - } - try - { - var result = await function(_isDisposed.Token) - .ConfigureAwait(false); - - tcs.SetResult(result); - } - catch (OperationCanceledException) - { - tcs.SetCanceled(); - } - catch (Exception ex) - { - tcs.SetException(ex); - } + var result = await function(_isDisposed.Token) + .ConfigureAwait(false); - }); - if (_isProcessing.CompareExchange(true, false) == false) + tcs.SetResult(result); + } + catch (OperationCanceledException) { - Task.Run(ProcessTaskQueue).ConfigureAwait(false); + tcs.SetCanceled(); + } + catch (Exception ex) + { + tcs.SetException(ex); } - return tcs.Task; - } - private async Task ProcessTaskQueue() + }); + if (_isProcessing.CompareExchange(true, false) == false) { - do - { - if (_taskQueue.TryDequeue(out Func function)) - { - await function().ConfigureAwait(false); - } - _isProcessing.Set(false); - } while (_taskQueue.Count > 0 && _isProcessing.CompareExchange(true, false) == false); + Task.Run(ProcessTaskQueue).ConfigureAwait(false); } + return tcs.Task; + } - public void Dispose() + private async Task ProcessTaskQueue() + { + do { - _isDisposed.Cancel(); - } + if (_taskQueue.TryDequeue(out Func function)) + { + await function().ConfigureAwait(false); + } + _isProcessing.Set(false); + } while (_taskQueue.Count > 0 && _isProcessing.CompareExchange(true, false) == false); + } + + public void Dispose() + { + _isDisposed.Cancel(); } -} +} \ No newline at end of file diff --git a/src/LittleForker/WatchParentProcessHostedService.cs b/src/LittleForker/WatchParentProcessHostedService.cs new file mode 100644 index 0000000..3f95f53 --- /dev/null +++ b/src/LittleForker/WatchParentProcessHostedService.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using Microsoft.Extensions.Options; + +namespace LittleForker; + +public class WatchParentProcessHostedService : IHostedService +{ + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly ILogger _logger; + private readonly WatchParentProcessHostedServiceOptions _options; + + public WatchParentProcessHostedService( + IHostApplicationLifetime applicationLifetime, + ILogger logger, + IOptions options) + { + _applicationLifetime = applicationLifetime; + _logger = logger; + _options = options.Value; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.ParentProcessId.HasValue) + { + _logger.LogWarning("Parent process Id not specified. Process will not be monitoring " + + "a parent process and cooperative shutdown is disabled."); + return Task.CompletedTask; + } + _logger.LogInformation("Starting child process with parent process Id {parentProcessId}.", _options.ParentProcessId); + + var process = Process.GetProcesses().SingleOrDefault(pr => pr.Id == _options.ParentProcessId!); + if (process == null) + { + _logger.LogError("Process with Id {parentProcessId} was not found. Exiting.", _options.ParentProcessId); + _applicationLifetime.StopApplication(); + return Task.CompletedTask; + } + + _logger.LogInformation("Process with Id {parentProcessId} found and will be monitored for exited.", _options.ParentProcessId); + try + { + process.EnableRaisingEvents = true; + process.Exited += (_, _) => + { + _logger.LogInformation("Parent process with Id {processId} exited. Stopping current application.", _options.ParentProcessId); + _applicationLifetime.StopApplication(); + }; + } + // Race condition: this may be thrown if the process has already exited before + // attaching to the Exited event + catch (InvalidOperationException ex) + { + _logger.LogInformation("Process with Id {processId} has already exited. Stopping current application.", _options.ParentProcessId); + _applicationLifetime.StopApplication(); + } + + if (process.HasExited) + { + _logger.LogInformation("Process with Id {processId} has already exited. Stopping current application.", _options.ParentProcessId); + _applicationLifetime.StopApplication(); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/LittleForker/WatchParentProcessHostedServiceOptions.cs b/src/LittleForker/WatchParentProcessHostedServiceOptions.cs new file mode 100644 index 0000000..b110ca1 --- /dev/null +++ b/src/LittleForker/WatchParentProcessHostedServiceOptions.cs @@ -0,0 +1,6 @@ +namespace LittleForker; + +public class WatchParentProcessHostedServiceOptions +{ + public int? ParentProcessId { get; set; } = null!; +} \ No newline at end of file diff --git a/src/NonTerminatingProcess/NonTerminatingProcess.csproj b/src/NonTerminatingProcess/NonTerminatingProcess.csproj index 53882dd..16e04cc 100644 --- a/src/NonTerminatingProcess/NonTerminatingProcess.csproj +++ b/src/NonTerminatingProcess/NonTerminatingProcess.csproj @@ -8,15 +8,8 @@ false - - - - - - - - + diff --git a/src/NonTerminatingProcess/NonTerminatingProcess.csproj.DotSettings b/src/NonTerminatingProcess/NonTerminatingProcess.csproj.DotSettings deleted file mode 100644 index 58ad6c8..0000000 --- a/src/NonTerminatingProcess/NonTerminatingProcess.csproj.DotSettings +++ /dev/null @@ -1,2 +0,0 @@ - - CSharp71 \ No newline at end of file diff --git a/src/NonTerminatingProcess/Program.cs b/src/NonTerminatingProcess/Program.cs index 1686476..fb4fe39 100644 --- a/src/NonTerminatingProcess/Program.cs +++ b/src/NonTerminatingProcess/Program.cs @@ -1,111 +1,24 @@ -using System; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics; using LittleForker; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging.Abstractions; -using Serilog; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; -namespace NonTerminatingProcess -{ - internal sealed class Program +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => { - // Yeah this process is supposed to be "non-terminating" - // but we don't want tons of orphaned instances running - // because of tests so it terminates after a long - // enough time (100 seconds) - private readonly CancellationTokenSource _shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(100)); - private readonly IConfigurationRoot _configRoot; - private readonly bool _ignoreShutdownSignal; - private readonly bool _exitWithNonZero; - - static Program() - { - Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateLogger(); - } - - private Program(string[] args) - { - _configRoot = new ConfigurationBuilder() - .AddCommandLine(args) - .AddEnvironmentVariables() - .Build(); - - // Running program with --debug=true will attach a debugger. - // Used to assist with debugging LittleForker. - if (_configRoot.GetValue("debug", false)) - { - Debugger.Launch(); - } - - _ignoreShutdownSignal = _configRoot.GetValue("ignore-shutdown-signal", false); - if (_ignoreShutdownSignal) - { - Log.Logger.Information("Will ignore Shutdown Signal"); - } - - _exitWithNonZero = _configRoot.GetValue("exit-with-non-zero", false); - if (_exitWithNonZero) - { - Log.Logger.Information("Will exit with non-zero exit code"); - } - } - - private async Task Run() - { - var pid = Process.GetCurrentProcess().Id; - Log.Logger.Information($"Long running process started. PID={pid}"); - - var parentPid = _configRoot.GetValue("ParentProcessId"); - - using (parentPid.HasValue - ? new ProcessExitedHelper(parentPid.Value, _ => ParentExited(parentPid.Value), new NullLoggerFactory()) - : NoopDisposable.Instance) - { - using (await CooperativeShutdown.Listen(ExitRequested, new NullLoggerFactory())) - { - // Poll the shutdown token in a tight loop - while(!_shutdown.IsCancellationRequested || _ignoreShutdownSignal) - { - await Task.Delay(100); - } - Log.Information("Exiting."); - } - } - - return _exitWithNonZero ? -1 : 0; - } - - static Task Main(string[] args) => new Program(args).Run(); - - private void ExitRequested() - { - Log.Logger.Information("Cooperative shutdown requested."); - - if (_ignoreShutdownSignal) - { - Log.Logger.Information("Shut down signal ignored."); - return; - } - - _shutdown.Cancel(); - } - - private void ParentExited(int processId) - { - Log.Logger.Information($"Parent process {processId} exited."); - _shutdown.Cancel(); - } - - private class NoopDisposable : IDisposable - { - public void Dispose() - {} - - internal static readonly IDisposable Instance = new NoopDisposable(); - } - } + services.AddChildProcessHostedService(context.Configuration); + services.AddCooperativeShutdownHostedService(context.Configuration); + services.AddHostedService(); + }) + .Build(); + +var config = host.Services.GetRequiredService(); +// Running program with --debug=true will attach a debugger. +// Used to assist with debugging LittleForker. +if (config.GetValue("debug", false)) +{ + Debugger.Launch(); } + +host.Run(); \ No newline at end of file diff --git a/src/NonTerminatingProcess/TimeoutHostedService.cs b/src/NonTerminatingProcess/TimeoutHostedService.cs new file mode 100644 index 0000000..0b9a00a --- /dev/null +++ b/src/NonTerminatingProcess/TimeoutHostedService.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +public class TimeoutHostedService : BackgroundService +{ + private readonly IHostApplicationLifetime _applicationLifetime; + + public TimeoutHostedService(IHostApplicationLifetime applicationLifetime) + { + _applicationLifetime = applicationLifetime; + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(100), stoppingToken); + _applicationLifetime.StopApplication(); + } +} \ No newline at end of file diff --git a/src/SelfTerminatingProcess/Program.cs b/src/SelfTerminatingProcess/Program.cs deleted file mode 100644 index 04fed75..0000000 --- a/src/SelfTerminatingProcess/Program.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace SelfTerminatingProcess -{ - public class Program - { - static void Main(string[] args) - { - Console.WriteLine("Done."); - } - } -} diff --git a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj b/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj deleted file mode 100644 index bf72eac..0000000 --- a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net6.0 - latest - false - ..\LittleForker.Tests\SelfTerminatingProcess\ - - - - Off - - - diff --git a/src/SelfTerminatingProcess/SelfTerminatingProcess.v3.ncrunchproject b/src/SelfTerminatingProcess/SelfTerminatingProcess.v3.ncrunchproject deleted file mode 100644 index eacd190..0000000 --- a/src/SelfTerminatingProcess/SelfTerminatingProcess.v3.ncrunchproject +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file