From e912d5563995d4b4f8cfabdf5546735f476bdfd5 Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 10:21:45 +0200 Subject: [PATCH 1/9] File scoped namespaces --- .../CooperativeShutdownTests.cs | 39 +- .../Infra/TestOutputHelperExtensions.cs | 13 +- .../ProcessExitedHelperTests.cs | 141 ++-- .../ProcessSupervisorTests.cs | 423 ++++++------ src/LittleForker.Tests/XunitLoggerFactory.cs | 21 +- src/LittleForker/CooperativeShutdown.cs | 285 ++++---- src/LittleForker/IProcessInfo.cs | 23 +- src/LittleForker/InterlockedBoolean.cs | 79 ++- src/LittleForker/ProcessExitedHelper.cs | 131 ++-- src/LittleForker/ProcessInfo.cs | 21 +- src/LittleForker/ProcessRunType.cs | 27 +- src/LittleForker/ProcessSupervisor.cs | 653 +++++++++--------- .../ProcessSupervisorExtensions.cs | 67 +- src/LittleForker/TaskExtensions.cs | 77 +-- src/LittleForker/TaskQueue.cs | 199 +++--- src/SelfTerminatingProcess/Program.cs | 13 +- 16 files changed, 1098 insertions(+), 1114 deletions(-) diff --git a/src/LittleForker.Tests/CooperativeShutdownTests.cs b/src/LittleForker.Tests/CooperativeShutdownTests.cs index 53a7e38..d516999 100644 --- a/src/LittleForker.Tests/CooperativeShutdownTests.cs +++ b/src/LittleForker.Tests/CooperativeShutdownTests.cs @@ -6,30 +6,29 @@ using Xunit; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class CooperativeShutdownTests { - public class CooperativeShutdownTests - { - private readonly ILoggerFactory _loggerFactory; + private readonly ILoggerFactory _loggerFactory; - public CooperativeShutdownTests(ITestOutputHelper outputHelper) - { - _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; - } + public CooperativeShutdownTests(ITestOutputHelper outputHelper) + { + _loggerFactory = new XunitLoggerFactory(outputHelper).LoggerFactory; + } - [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); + [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 CooperativeShutdown.SignalExit(Process.GetCurrentProcess().Id, _loggerFactory); + await CooperativeShutdown.SignalExit(Process.GetCurrentProcess().Id, _loggerFactory); - (await exitCalled.Task).ShouldBeTrue(); + (await exitCalled.Task).ShouldBeTrue(); - listener.Dispose(); - } + listener.Dispose(); } -} +} \ 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/ProcessExitedHelperTests.cs b/src/LittleForker.Tests/ProcessExitedHelperTests.cs index de2ae2b..790dc46 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)) - { - var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); - processId.ShouldBe(-1); - } - } + [Fact] + public async Task When_parent_process_exits_than_should_call_parent_exited_callback() + { + // 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; - [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; - - // Monitor parent - var parentExited = new TaskCompletionSource(); - using (new ProcessExitedHelper(supervisor.ProcessInfo.Id, watcher => parentExited.SetResult(watcher.ProcessId), _loggerFactory)) - { - // Stop parent - await supervisor.Stop(TimeSpan.FromSeconds(2)); - var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); - processId.Value.ShouldBeGreaterThan(0); - } + // Stop parent + await supervisor.Stop(TimeSpan.FromSeconds(2)); + var processId = await parentExited.Task.TimeoutAfter(TimeSpan.FromSeconds(2)); + processId.Value.ShouldBeGreaterThan(0); } + } - [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; + [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; - // 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 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; - // 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..d381837 100644 --- a/src/LittleForker.Tests/ProcessSupervisorTests.cs +++ b/src/LittleForker.Tests/ProcessSupervisorTests.cs @@ -6,220 +6,219 @@ using Xunit; using Xunit.Abstractions; -namespace LittleForker +namespace LittleForker; + +public class ProcessSupervisorTests : IDisposable { - public class ProcessSupervisorTests : IDisposable + private readonly ITestOutputHelper _outputHelper; + private readonly ILoggerFactory _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(); + + await stateIsStartFailed; + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); + supervisor.OnStartException.ShouldNotBeNull(); + + _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(); + + supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); + supervisor.OnStartException.ShouldNotBeNull(); + + _outputHelper.WriteLine(supervisor.OnStartException.ToString()); + } + + [Fact] + public async Task Given_short_running_exe_then_should_run_to_exit() { - private readonly ITestOutputHelper _outputHelper; - private readonly ILoggerFactory _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(); - - await stateIsStartFailed; - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - - _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(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - - _outputHelper.WriteLine(supervisor.OnStartException.ToString()); - } - - [Fact] - public async Task Given_short_running_exe_then_should_run_to_exit() - { - 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() - { - 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); - } + 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() + { + 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); + } - [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() - { - 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() - { - 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}"); - } + [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() + { + 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() + { + 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}"); + } - [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() - { - 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(); - } - - [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_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() + { + 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(); + } + + [Fact] + public void WriteDotGraph() + { + var processController = new ProcessSupervisor( + _loggerFactory, + ProcessRunType.NonTerminating, + Environment.CurrentDirectory, + "invalid.exe"); + _outputHelper.WriteLine(processController.GetDotGraph()); + } + + public void Dispose() + { } -} +} \ 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..f1ab67f 100644 --- a/src/LittleForker/CooperativeShutdown.cs +++ b/src/LittleForker/CooperativeShutdown.cs @@ -6,186 +6,185 @@ 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)}.{typeof(CooperativeShutdown).Name}")); - 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 async Task SignalExit(int processId, ILoggerFactory loggerFactory) + { + var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}"); + var pipeName = GetPipeName(processId); + 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(); - } + // message transmission mode is not supported on Unix + var pipe = new NamedPipeServerStream(_pipeName, + PipeDirection.InOut, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.None); - 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); - - _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/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/ProcessExitedHelper.cs b/src/LittleForker/ProcessExitedHelper.cs index be6929d..0a2f802 100644 --- a/src/LittleForker/ProcessExitedHelper.cs +++ b/src/LittleForker/ProcessExitedHelper.cs @@ -4,85 +4,84 @@ 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 index fec2fb1..9151d1e 100644 --- a/src/LittleForker/ProcessRunType.cs +++ b/src/LittleForker/ProcessRunType.cs @@ -1,18 +1,17 @@ -namespace LittleForker +namespace LittleForker; + +/// +/// Defined how a process is expected to run. +/// +public enum ProcessRunType { /// - /// Defined how a process is expected to run. + /// Processes that are expected to terminate of their own accord. /// - 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 - } + SelfTerminating, + /// + /// Processes that are not expected to terminate of their own + /// accord and that must be cooperatively shutdown or killed. + /// + NonTerminating } \ No newline at end of file diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index b97ba2d..3aa67c7 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -6,306 +6,350 @@ 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 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(); + /// - /// Launches an process and tracks it's lifecycle . + /// The state a process is in. /// - public class ProcessSupervisor : IDisposable + public enum State + { + NotStarted, + Running, + StartFailed, + Stopping, + ExitedSuccessfully, + ExitedWithError, + ExitedUnexpectedly, + ExitedKilled + } + + private enum Trigger { - 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 + 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) + { + _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 => { - NotStarted, - Running, - StartFailed, - Stopping, - ExitedSuccessfully, - ExitedWithError, - ExitedUnexpectedly, - ExitedKilled - } + _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; - private enum Trigger + /// + /// 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(() => { - Start, - StartError, - Stop, - ProcessExit - } + _killed = false; + _processStateMachine.Fire(Trigger.Start); + }); - /// - /// 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) + /// + /// 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 { - _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 => + var processStartInfo = new ProcessStartInfo(_processPath) { - _logger.LogInformation($"State transition from {transition.Source} to {transition.Destination}"); - StateChanged?.Invoke(transition.Destination); - }); - } + 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) + { + processStartInfo.EnvironmentVariables[key] = _environmentVariables[key]; + } + } - /// - /// 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(() => + // Start the process and capture it's output. + _process = new Process { - _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) + 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); + } + catch (Exception ex) { - await await _taskQueue - .Enqueue(() => _processStateMachine.FireAsync(_stopTrigger, timeout)) - .ConfigureAwait(false); + _logger.LogError(ex, $"Failed to start process {_processPath}"); + _processStateMachine.Fire(_startErrorTrigger, ex); } + } + + private void OnStartError(Exception ex) + { + OnStartException = ex; + _process?.Dispose(); + ProcessInfo = null; + } - private void OnStart() + private async Task OnStop(TimeSpan? timeout) + { + if (!timeout.HasValue || timeout.Value <= TimeSpan.Zero) { - OnStartException = null; try { - var processStartInfo = new ProcessStartInfo(_processPath) - { - 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) - { - 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); + _logger.LogInformation($"Killing process {_process.Id}"); + _killed = true; + _process.Kill(); } catch (Exception ex) { - _logger.LogError(ex, $"Failed to start process {_processPath}"); - _processStateMachine.Fire(_startErrorTrigger, 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."); } } - - private void OnStartError(Exception ex) - { - OnStartException = ex; - _process?.Dispose(); - ProcessInfo = null; - } - - private async Task OnStop(TimeSpan? timeout) + else { - if (!timeout.HasValue || timeout.Value <= TimeSpan.Zero) + 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 +357,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/TaskExtensions.cs b/src/LittleForker/TaskExtensions.cs index 827c267..6d0f258 100644 --- a/src/LittleForker/TaskExtensions.cs +++ b/src/LittleForker/TaskExtensions.cs @@ -2,57 +2,56 @@ 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) + internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + { + using (var timeoutCancellationTokenSource = new CancellationTokenSource()) { - return task.IsCompleted // fast-path optimization - ? task - : task.ContinueWith( - completedTask => completedTask.GetAwaiter().GetResult(), - cancellationToken, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } + var completedTask = await Task + .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) + .ConfigureAwait(false); - internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) - { - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) + if (completedTask == task) { - 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; } + + throw new TimeoutException("The operation has timed out."); } + } - internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) + { + using (var timeoutCancellationTokenSource = new CancellationTokenSource()) { - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) + var completedTask = await Task + .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) + .ConfigureAwait(false); + if (completedTask == task) { - 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."); + 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..ac8c030 100644 --- a/src/LittleForker/TaskQueue.cs +++ b/src/LittleForker/TaskQueue.cs @@ -3,130 +3,129 @@ 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 ConcurrentQueue>(); + private readonly CancellationTokenSource _isDisposed = new CancellationTokenSource(); + private readonly InterlockedBoolean _isProcessing = new InterlockedBoolean(); + + /// - /// 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 co-operatively 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) + { + return EnqueueInternal(_taskQueue, function); + } - /// - /// 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) + 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); + var result = await function(_isDisposed.Token) + .ConfigureAwait(false); - tcs.SetResult(result); - } - catch (OperationCanceledException) - { - tcs.SetCanceled(); - } - catch (Exception ex) - { - tcs.SetException(ex); - } - - }); - 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/SelfTerminatingProcess/Program.cs b/src/SelfTerminatingProcess/Program.cs index 04fed75..88cd600 100644 --- a/src/SelfTerminatingProcess/Program.cs +++ b/src/SelfTerminatingProcess/Program.cs @@ -1,12 +1,11 @@ using System; -namespace SelfTerminatingProcess +namespace SelfTerminatingProcess; + +public class Program { - public class Program + static void Main(string[] args) { - static void Main(string[] args) - { - Console.WriteLine("Done."); - } + Console.WriteLine("Done."); } -} +} \ No newline at end of file From 01c23857f9c4b1e7d4b73b5e189508f8b0c342fc Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 10:23:00 +0200 Subject: [PATCH 2/9] new () --- src/LittleForker/ProcessSupervisor.cs | 5 ++--- src/LittleForker/TaskQueue.cs | 11 +++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index 3aa67c7..08ba575 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -20,13 +20,12 @@ public class ProcessSupervisor : IDisposable 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 StateMachine _processStateMachine = new(State.NotStarted, FiringMode.Immediate); private readonly string _workingDirectory; private Process _process; private readonly ILoggerFactory _loggerFactory; private bool _killed; - private readonly TaskQueue _taskQueue = new TaskQueue(); + private readonly TaskQueue _taskQueue = new(); /// /// The state a process is in. diff --git a/src/LittleForker/TaskQueue.cs b/src/LittleForker/TaskQueue.cs index ac8c030..912add6 100644 --- a/src/LittleForker/TaskQueue.cs +++ b/src/LittleForker/TaskQueue.cs @@ -11,10 +11,9 @@ namespace LittleForker; /// internal class TaskQueue : IDisposable { - private readonly ConcurrentQueue> _taskQueue = new ConcurrentQueue>(); - private readonly CancellationTokenSource _isDisposed = new CancellationTokenSource(); - private readonly InterlockedBoolean _isProcessing = new InterlockedBoolean(); - + private readonly ConcurrentQueue> _taskQueue = new(); + private readonly CancellationTokenSource _isDisposed = new(); + private readonly InterlockedBoolean _isProcessing = new(); /// /// Enqueues a task for processing. @@ -49,7 +48,7 @@ public Task Enqueue(Func function) /// /// Enqueues a task for processing. /// - /// The operation to invoke that is co-operatively cancelable. + /// The operation to invoke that is cooperatively cancelable. /// A task representing the operation. Awaiting is optional. public Task Enqueue(Func function) { @@ -64,7 +63,7 @@ public Task Enqueue(Func function) /// /// Enqueues a task for processing. /// - /// The operation to invoke that is co-operatively cancelable. + /// The operation to invoke that is cooperatively cancelable. /// A task representing the operation. Awaiting is optional. public Task Enqueue(Func> function) { From 05049d76e09efb1141c09fef4a35030162ffc54d Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 11:04:46 +0200 Subject: [PATCH 3/9] top level programs --- .../NonTerminatingProcess.csproj | 2 +- src/NonTerminatingProcess/Program.cs | 148 ++++++++---------- src/SelfTerminatingProcess/Program.cs | 10 +- .../SelfTerminatingProcess.csproj | 2 +- 4 files changed, 64 insertions(+), 98 deletions(-) diff --git a/src/NonTerminatingProcess/NonTerminatingProcess.csproj b/src/NonTerminatingProcess/NonTerminatingProcess.csproj index 53882dd..8f4eef9 100644 --- a/src/NonTerminatingProcess/NonTerminatingProcess.csproj +++ b/src/NonTerminatingProcess/NonTerminatingProcess.csproj @@ -3,7 +3,7 @@ Exe net6.0 - latest + preview ..\LittleForker.Tests\NonTerminatingProcess\ false diff --git a/src/NonTerminatingProcess/Program.cs b/src/NonTerminatingProcess/Program.cs index 1686476..fbf7aa8 100644 --- a/src/NonTerminatingProcess/Program.cs +++ b/src/NonTerminatingProcess/Program.cs @@ -7,105 +7,79 @@ using Microsoft.Extensions.Logging.Abstractions; using Serilog; -namespace NonTerminatingProcess +var logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); +var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(100)); +var 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)) { - internal sealed class Program - { - // 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; + Debugger.Launch(); +} - static Program() - { - Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateLogger(); - } +var ignoreShutdownSignal = configRoot.GetValue("ignore-shutdown-signal", false); +if (ignoreShutdownSignal) +{ + Log.Logger.Information("Will ignore Shutdown Signal"); +} - private Program(string[] args) - { - _configRoot = new ConfigurationBuilder() - .AddCommandLine(args) - .AddEnvironmentVariables() - .Build(); +var exitWithNonZero = configRoot.GetValue("exit-with-non-zero", false); +if (exitWithNonZero) +{ + Log.Logger.Information("Will exit with non-zero exit code"); +} - // 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"); - } +var pid = Process.GetCurrentProcess().Id; +Log.Logger.Information($"Long running process started. PID={pid}"); - _exitWithNonZero = _configRoot.GetValue("exit-with-non-zero", false); - if (_exitWithNonZero) - { - Log.Logger.Information("Will exit with non-zero exit code"); - } - } +var parentPid = configRoot.GetValue("ParentProcessId"); - private async Task Run() +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) { - 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; + await Task.Delay(100); } + Log.Information("Exiting."); + } +} - static Task Main(string[] args) => new Program(args).Run(); +return exitWithNonZero ? -1 : 0; - private void ExitRequested() - { - Log.Logger.Information("Cooperative shutdown requested."); +void ExitRequested() +{ + Log.Logger.Information("Cooperative shutdown requested."); - if (_ignoreShutdownSignal) - { - Log.Logger.Information("Shut down signal ignored."); - return; - } + if (ignoreShutdownSignal) + { + Log.Logger.Information("Shut down signal ignored."); + return; + } - _shutdown.Cancel(); - } + shutdown.Cancel(); +} - private void ParentExited(int processId) - { - Log.Logger.Information($"Parent process {processId} exited."); - _shutdown.Cancel(); - } +void ParentExited(int processId) +{ + Log.Logger.Information($"Parent process {processId} exited."); + shutdown.Cancel(); +} - private class NoopDisposable : IDisposable - { - public void Dispose() - {} +class NoopDisposable : IDisposable +{ + public void Dispose() + { } - internal static readonly IDisposable Instance = new NoopDisposable(); - } - } -} + internal static readonly IDisposable Instance = new NoopDisposable(); +} \ No newline at end of file diff --git a/src/SelfTerminatingProcess/Program.cs b/src/SelfTerminatingProcess/Program.cs index 88cd600..9228d66 100644 --- a/src/SelfTerminatingProcess/Program.cs +++ b/src/SelfTerminatingProcess/Program.cs @@ -1,11 +1,3 @@ using System; -namespace SelfTerminatingProcess; - -public class Program -{ - static void Main(string[] args) - { - Console.WriteLine("Done."); - } -} \ No newline at end of file +Console.WriteLine("Done."); diff --git a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj b/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj index bf72eac..bb0e294 100644 --- a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj +++ b/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj @@ -3,7 +3,7 @@ Exe net6.0 - latest + preview false ..\LittleForker.Tests\SelfTerminatingProcess\ From ba16738f183572224bbd28ef05f57ef06e9f792e Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 11:06:04 +0200 Subject: [PATCH 4/9] Use environment process it instead. --- src/NonTerminatingProcess/Program.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/NonTerminatingProcess/Program.cs b/src/NonTerminatingProcess/Program.cs index fbf7aa8..f2339df 100644 --- a/src/NonTerminatingProcess/Program.cs +++ b/src/NonTerminatingProcess/Program.cs @@ -35,8 +35,8 @@ Log.Logger.Information("Will exit with non-zero exit code"); } -var pid = Process.GetCurrentProcess().Id; -Log.Logger.Information($"Long running process started. PID={pid}"); +var pid = Environment.ProcessId; +logger.Information($"Long running process started. PID={pid}"); var parentPid = configRoot.GetValue("ParentProcessId"); From 6bc135c068fb8e0081b6d5bc41b18a987d4024b9 Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 11:42:56 +0200 Subject: [PATCH 5/9] Start of a ChildProcessHostedService --- src/LittleForker/ChildProcessHostedService.cs | 50 +++++++++++++++++++ src/LittleForker/LittleForker.csproj | 4 ++ 2 files changed, 54 insertions(+) create mode 100644 src/LittleForker/ChildProcessHostedService.cs diff --git a/src/LittleForker/ChildProcessHostedService.cs b/src/LittleForker/ChildProcessHostedService.cs new file mode 100644 index 0000000..6a76173 --- /dev/null +++ b/src/LittleForker/ChildProcessHostedService.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace LittleForker; + +public class ChildProcessHostedService : IHostedService +{ + private readonly IConfiguration _configuration; + private readonly IHostApplicationLifetime _hostApplicationLifetime; + private readonly ILogger _logger; + private readonly ChildProcessConfig _config; + + public ChildProcessHostedService( + IConfiguration configurationRoot, + IHostApplicationLifetime hostApplicationLifetime, + ILogger logger) + { + _configuration = configurationRoot; + _hostApplicationLifetime = hostApplicationLifetime; + _logger = logger; + _config = new ChildProcessConfig(); + _configuration.Bind(_config); + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_config.ParentProcessId)) + { + _logger.LogWarning("Parent process Id not specified. Child process will not be monitoring " + + "a parent process and cooperative shutdown is not enabled."); + return Task.CompletedTask; + } + _logger.LogInformation($"Starting child process with parent process Id {_config.ParentProcessId}."); + + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation($"Stopping child process with parent process Id {_config.ParentProcessId}."); + return Task.CompletedTask; + } + + private class ChildProcessConfig + { + public string? ParentProcessId { get; set; } = null!; + } +} \ No newline at end of file diff --git a/src/LittleForker/LittleForker.csproj b/src/LittleForker/LittleForker.csproj index 10b2ff4..279d236 100644 --- a/src/LittleForker/LittleForker.csproj +++ b/src/LittleForker/LittleForker.csproj @@ -12,9 +12,13 @@ true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + true + enable + + From 499582e491fab5ed8837c1d87e84c00776674ee1 Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 11:43:22 +0200 Subject: [PATCH 6/9] Use nameof --- src/LittleForker/CooperativeShutdown.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LittleForker/CooperativeShutdown.cs b/src/LittleForker/CooperativeShutdown.cs index f1ab67f..50d0b9c 100644 --- a/src/LittleForker/CooperativeShutdown.cs +++ b/src/LittleForker/CooperativeShutdown.cs @@ -40,7 +40,7 @@ public static Task Listen(Action shutdownRequested, ILoggerFactory var listener = new CooperativeShutdownListener( GetPipeName(Process.GetCurrentProcess().Id), shutdownRequested, - loggerFactory.CreateLogger($"{nameof(LittleForker)}.{typeof(CooperativeShutdown).Name}")); + loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}")); Task.Run(async () => { From e95c0865c18d6afe46b3210e227f8101287a94a7 Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 13:26:14 +0200 Subject: [PATCH 7/9] wip --- .../ProcessExitedHelperTests.cs | 36 +++--- .../ProcessSupervisorTests.cs | 103 ++++++++---------- src/LittleForker/ChildProcessHostedService.cs | 50 --------- .../CooperativeShutdownHostedService.cs | 91 ++++++++++++++++ ...CooperativeShutdownHostedServiceOptions.cs | 6 + src/LittleForker/LittleForker.csproj | 3 + src/LittleForker/ProcessExitedHelper.cs | 6 +- src/LittleForker/ProcessSupervisor.cs | 85 +++++++++------ .../ServiceCollectionExtensions.cs | 26 +++++ src/LittleForker/TaskExtensions.cs | 50 ++++----- .../WatchParentProcessHostedService.cs | 70 ++++++++++++ .../WatchParentProcessHostedServiceOptions.cs | 6 + .../NonTerminatingProcess.csproj | 11 +- .../NonTerminatingProcess.csproj.DotSettings | 2 - src/NonTerminatingProcess/Program.cs | 87 +++------------ .../TimeoutHostedService.cs | 19 ++++ 16 files changed, 372 insertions(+), 279 deletions(-) delete mode 100644 src/LittleForker/ChildProcessHostedService.cs create mode 100644 src/LittleForker/CooperativeShutdownHostedService.cs create mode 100644 src/LittleForker/CooperativeShutdownHostedServiceOptions.cs create mode 100644 src/LittleForker/ServiceCollectionExtensions.cs create mode 100644 src/LittleForker/WatchParentProcessHostedService.cs create mode 100644 src/LittleForker/WatchParentProcessHostedServiceOptions.cs delete mode 100644 src/NonTerminatingProcess/NonTerminatingProcess.csproj.DotSettings create mode 100644 src/NonTerminatingProcess/TimeoutHostedService.cs diff --git a/src/LittleForker.Tests/ProcessExitedHelperTests.cs b/src/LittleForker.Tests/ProcessExitedHelperTests.cs index 790dc46..dd06697 100644 --- a/src/LittleForker.Tests/ProcessExitedHelperTests.cs +++ b/src/LittleForker.Tests/ProcessExitedHelperTests.cs @@ -33,12 +33,12 @@ public async Task When_parent_process_does_not_exist_then_should_call_parent_exi public async Task When_parent_process_exits_than_should_call_parent_exited_callback() { // Start parent - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); + var settings = + new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + 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(); @@ -59,24 +59,24 @@ public async Task When_parent_process_exits_than_should_call_parent_exited_callb 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"); + var parentSettings = + new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" + }; + + 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}"); + var childSettings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, 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); diff --git a/src/LittleForker.Tests/ProcessSupervisorTests.cs b/src/LittleForker.Tests/ProcessSupervisorTests.cs index d381837..d68e656 100644 --- a/src/LittleForker.Tests/ProcessSupervisorTests.cs +++ b/src/LittleForker.Tests/ProcessSupervisorTests.cs @@ -8,7 +8,7 @@ namespace LittleForker; -public class ProcessSupervisorTests : IDisposable +public class ProcessSupervisorTests { private readonly ITestOutputHelper _outputHelper; private readonly ILoggerFactory _loggerFactory; @@ -22,7 +22,8 @@ public ProcessSupervisorTests(ITestOutputHelper outputHelper) [Fact] public async Task Given_invalid_process_path_then_state_should_be_StartError() { - var supervisor = new ProcessSupervisor(_loggerFactory, ProcessRunType.NonTerminating, "c:/", "invalid.exe"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, "c:/", "invalid.exe"); + var supervisor = new ProcessSupervisor(settings, _loggerFactory); var stateIsStartFailed = supervisor.WhenStateIs(ProcessSupervisor.State.StartFailed); await supervisor.Start(); @@ -36,7 +37,8 @@ public async Task Given_invalid_process_path_then_state_should_be_StartError() [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"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, "c:/does_not_exist", "git.exe"); + var supervisor = new ProcessSupervisor(settings, _loggerFactory); await supervisor.Start(); supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); @@ -49,13 +51,12 @@ public async Task Given_invalid_working_directory_then_state_should_be_StartErro public async Task Given_short_running_exe_then_should_run_to_exit() { var envVars = new StringDictionary {{"a", "b"}}; - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.SelfTerminating, - Environment.CurrentDirectory, - "dotnet", - "./SelfTerminatingProcess/SelfTerminatingProcess.dll", - envVars); + var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "dotnet") + { + 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); @@ -73,12 +74,11 @@ public async Task Given_short_running_exe_then_should_run_to_exit() [Fact] public async Task Given_non_terminating_process_then_should_exit_when_stopped() { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + 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(); @@ -96,12 +96,11 @@ public async Task Given_non_terminating_process_then_should_exit_when_stopped() [Fact] public async Task Can_restart_a_stopped_short_running_process() { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.SelfTerminating, - Environment.CurrentDirectory, - "dotnet", - "./SelfTerminatingProcess/SelfTerminatingProcess.dll"); + var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "dotnet") + { + Arguments = "./SelfTerminatingProcess/SelfTerminatingProcess.dll" + }; + var supervisor = new ProcessSupervisor(settings, _loggerFactory); supervisor.OutputDataReceived += data => _outputHelper.WriteLine2(data); var stateIsStopped = supervisor.WhenStateIs(ProcessSupervisor.State.ExitedSuccessfully); await supervisor.Start(); @@ -114,12 +113,11 @@ public async Task Can_restart_a_stopped_short_running_process() [Fact] public async Task Can_restart_a_stopped_long_running_process() { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + 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(); @@ -136,12 +134,11 @@ public async Task Can_restart_a_stopped_long_running_process() [Fact] public async Task When_stop_a_non_terminating_process_without_a_timeout_then_should_exit_killed() { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, - "dotnet", - "./NonTerminatingProcess/NonTerminatingProcess.dll"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + 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(); @@ -154,12 +151,11 @@ public async Task When_stop_a_non_terminating_process_without_a_timeout_then_sho [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"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + { + 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(); @@ -172,12 +168,11 @@ public async Task When_stop_a_non_terminating_process_that_does_not_shutdown_wit [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"); + var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, 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(); @@ -191,11 +186,9 @@ public async Task When_stop_a_non_terminating_process_with_non_zero_then_should_ [Fact] public async Task Can_attempt_to_restart_a_failed_short_running_process() { - var supervisor = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, + var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "invalid.exe"); + var supervisor = new ProcessSupervisor(settings, _loggerFactory); await supervisor.Start(); supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); @@ -210,15 +203,9 @@ public async Task Can_attempt_to_restart_a_failed_short_running_process() [Fact] public void WriteDotGraph() { - var processController = new ProcessSupervisor( - _loggerFactory, - ProcessRunType.NonTerminating, - Environment.CurrentDirectory, + var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "invalid.exe"); + var processController = new ProcessSupervisor(settings, _loggerFactory); _outputHelper.WriteLine(processController.GetDotGraph()); } - - public void Dispose() - { - } } \ No newline at end of file diff --git a/src/LittleForker/ChildProcessHostedService.cs b/src/LittleForker/ChildProcessHostedService.cs deleted file mode 100644 index 6a76173..0000000 --- a/src/LittleForker/ChildProcessHostedService.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace LittleForker; - -public class ChildProcessHostedService : IHostedService -{ - private readonly IConfiguration _configuration; - private readonly IHostApplicationLifetime _hostApplicationLifetime; - private readonly ILogger _logger; - private readonly ChildProcessConfig _config; - - public ChildProcessHostedService( - IConfiguration configurationRoot, - IHostApplicationLifetime hostApplicationLifetime, - ILogger logger) - { - _configuration = configurationRoot; - _hostApplicationLifetime = hostApplicationLifetime; - _logger = logger; - _config = new ChildProcessConfig(); - _configuration.Bind(_config); - } - - public Task StartAsync(CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(_config.ParentProcessId)) - { - _logger.LogWarning("Parent process Id not specified. Child process will not be monitoring " + - "a parent process and cooperative shutdown is not enabled."); - return Task.CompletedTask; - } - _logger.LogInformation($"Starting child process with parent process Id {_config.ParentProcessId}."); - - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - _logger.LogInformation($"Stopping child process with parent process Id {_config.ParentProcessId}."); - return Task.CompletedTask; - } - - private class ChildProcessConfig - { - public string? ParentProcessId { get; set; } = null!; - } -} \ 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/LittleForker.csproj b/src/LittleForker/LittleForker.csproj index 279d236..1125101 100644 --- a/src/LittleForker/LittleForker.csproj +++ b/src/LittleForker/LittleForker.csproj @@ -18,8 +18,11 @@ + + + all diff --git a/src/LittleForker/ProcessExitedHelper.cs b/src/LittleForker/ProcessExitedHelper.cs index 0a2f802..eb34e1d 100644 --- a/src/LittleForker/ProcessExitedHelper.cs +++ b/src/LittleForker/ProcessExitedHelper.cs @@ -1,7 +1,5 @@ using System; using System.Diagnostics; -using System.Linq; -using System.Threading; using Microsoft.Extensions.Logging; namespace LittleForker; @@ -12,8 +10,8 @@ namespace LittleForker; /// public sealed class ProcessExitedHelper : IDisposable { - private int _processExitedRaised; - private readonly Process _process; + private int _processExitedRaised; + private readonly Process? _process; /// /// Initializes a new instance of diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index 08ba575..cea81bb 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -1,13 +1,37 @@ -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; +public class ProcessSupervisorSettings +{ + public ProcessRunType ProcessRunType { get; } + 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( + ProcessRunType processRunType, + string workingDirectory, + string processPath) + { + ProcessRunType = processRunType; + WorkingDirectory = workingDirectory; + ProcessPath = processPath; + } +} + /// /// Launches an process and tracks it's lifecycle . /// @@ -20,12 +44,13 @@ public class ProcessSupervisor : IDisposable 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 ILoggerFactory _loggerFactory; - private bool _killed; - private readonly TaskQueue _taskQueue = new(); + 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(); /// /// The state a process is in. @@ -62,6 +87,7 @@ private enum Trigger /// /// The process run type. /// + /// /// /// A logger factory. /// @@ -75,22 +101,13 @@ private enum Trigger /// 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) + ProcessSupervisorSettings settings, + ILoggerFactory loggerFactory) { - _loggerFactory = loggerFactory; - _workingDirectory = workingDirectory; - _processPath = processPath; - _arguments = arguments ?? string.Empty; - _environmentVariables = environmentVariables; - _captureStdErr = captureStdErr; + _settings = settings; + _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessSupervisor)}-{processPath}"); + _logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(ProcessSupervisor)}-{settings.ProcessPath}"); _processStateMachine .Configure(State.NotStarted) @@ -105,21 +122,21 @@ public ProcessSupervisor( .PermitIf( Trigger.ProcessExit, State.ExitedSuccessfully, - () => processRunType == ProcessRunType.SelfTerminating + () => settings.ProcessRunType == ProcessRunType.SelfTerminating && _process.HasExited && _process.ExitCode == 0, "SelfTerminating && ExitCode==0") .PermitIf( Trigger.ProcessExit, State.ExitedWithError, - () => processRunType == ProcessRunType.SelfTerminating + () => settings.ProcessRunType == ProcessRunType.SelfTerminating && _process.HasExited && _process.ExitCode != 0, "SelfTerminating && ExitCode!=0") .PermitIf( Trigger.ProcessExit, State.ExitedUnexpectedly, - () => processRunType == ProcessRunType.NonTerminating + () => settings.ProcessRunType == ProcessRunType.NonTerminating && _process.HasExited, "NonTerminating and died.") .Permit(Trigger.Stop, State.Stopping) @@ -133,19 +150,19 @@ public ProcessSupervisor( .Configure(State.Stopping) .OnEntryFromAsync(_stopTrigger, OnStop) .PermitIf(Trigger.ProcessExit, State.ExitedSuccessfully, - () => processRunType == ProcessRunType.NonTerminating + () => settings.ProcessRunType == ProcessRunType.NonTerminating && !_killed && _process.HasExited && _process.ExitCode == 0, "NonTerminating and shut down cleanly") .PermitIf(Trigger.ProcessExit, State.ExitedWithError, - () => processRunType == ProcessRunType.NonTerminating + () => settings.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 + () => settings.ProcessRunType == ProcessRunType.NonTerminating && _killed && _process.HasExited && _process.ExitCode != 0, @@ -183,7 +200,7 @@ public ProcessSupervisor( /// /// Information about the launched process. /// - public IProcessInfo ProcessInfo { get; private set; } + public IProcessInfo? ProcessInfo { get; private set; } public State CurrentState => _processStateMachine.State; @@ -249,13 +266,11 @@ private void OnStart() }; // 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 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 6d0f258..d517f5c 100644 --- a/src/LittleForker/TaskExtensions.cs +++ b/src/LittleForker/TaskExtensions.cs @@ -1,8 +1,4 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace LittleForker; +namespace LittleForker; internal static class TaskExtensions { @@ -21,37 +17,33 @@ internal static Task WithCancellation(this Task task, CancellationToken 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); + 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."); + if (completedTask == task) + { + timeoutCancellationTokenSource.Cancel(); + await task.ConfigureAwait(false); + return; } + + throw new TimeoutException("The operation has timed out."); } internal static async Task TimeoutAfter(this Task task, TimeSpan timeout) { - using (var timeoutCancellationTokenSource = new CancellationTokenSource()) + using var timeoutCancellationTokenSource = new CancellationTokenSource(); + var completedTask = await Task + .WhenAny(task, Task.Delay(timeout, timeoutCancellationTokenSource.Token)) + .ConfigureAwait(false); + if (completedTask == task) { - 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."); + 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/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 8f4eef9..16e04cc 100644 --- a/src/NonTerminatingProcess/NonTerminatingProcess.csproj +++ b/src/NonTerminatingProcess/NonTerminatingProcess.csproj @@ -3,20 +3,13 @@ Exe net6.0 - preview + latest ..\LittleForker.Tests\NonTerminatingProcess\ 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 f2339df..fb4fe39 100644 --- a/src/NonTerminatingProcess/Program.cs +++ b/src/NonTerminatingProcess/Program.cs @@ -1,85 +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; -var logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateLogger(); -var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(100)); -var configRoot = new ConfigurationBuilder() - .AddCommandLine(args) - .AddEnvironmentVariables() +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + 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 (configRoot.GetValue("debug", false)) +if (config.GetValue("debug", false)) { Debugger.Launch(); } -var ignoreShutdownSignal = configRoot.GetValue("ignore-shutdown-signal", false); -if (ignoreShutdownSignal) -{ - Log.Logger.Information("Will ignore Shutdown Signal"); -} - -var exitWithNonZero = configRoot.GetValue("exit-with-non-zero", false); -if (exitWithNonZero) -{ - Log.Logger.Information("Will exit with non-zero exit code"); -} - -var pid = Environment.ProcessId; -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; - -void ExitRequested() -{ - Log.Logger.Information("Cooperative shutdown requested."); - - if (ignoreShutdownSignal) - { - Log.Logger.Information("Shut down signal ignored."); - return; - } - - shutdown.Cancel(); -} - -void ParentExited(int processId) -{ - Log.Logger.Information($"Parent process {processId} exited."); - shutdown.Cancel(); -} - -class NoopDisposable : IDisposable -{ - public void Dispose() - { } - - internal static readonly IDisposable Instance = new NoopDisposable(); -} \ No newline at end of file +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 From 4742130493814a5fc7aba4f5da76c031ff4f05d7 Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 13:33:00 +0200 Subject: [PATCH 8/9] Dropping support for self-terminating child processes. Focus on long living only. --- LittleForker.sln | 6 --- .../LittleForker.Tests.csproj | 3 -- .../ProcessExitedHelperTests.cs | 6 +-- .../ProcessSupervisorTests.cs | 52 ++++-------------- src/LittleForker/ProcessRunType.cs | 17 ------ src/LittleForker/ProcessSupervisor.cs | 54 ++----------------- src/LittleForker/ProcessSupervisorSettings.cs | 26 +++++++++ src/SelfTerminatingProcess/Program.cs | 3 -- .../SelfTerminatingProcess.csproj | 15 ------ .../SelfTerminatingProcess.v3.ncrunchproject | 7 --- 10 files changed, 43 insertions(+), 146 deletions(-) delete mode 100644 src/LittleForker/ProcessRunType.cs create mode 100644 src/LittleForker/ProcessSupervisorSettings.cs delete mode 100644 src/SelfTerminatingProcess/Program.cs delete mode 100644 src/SelfTerminatingProcess/SelfTerminatingProcess.csproj delete mode 100644 src/SelfTerminatingProcess/SelfTerminatingProcess.v3.ncrunchproject 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/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 dd06697..05ba2a4 100644 --- a/src/LittleForker.Tests/ProcessExitedHelperTests.cs +++ b/src/LittleForker.Tests/ProcessExitedHelperTests.cs @@ -34,7 +34,7 @@ public async Task When_parent_process_exits_than_should_call_parent_exited_callb { // Start parent var settings = - new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" }; @@ -60,7 +60,7 @@ public async Task When_parent_process_exits_then_child_process_should_also_do_so { // Start parent var parentSettings = - new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" }; @@ -72,7 +72,7 @@ public async Task When_parent_process_exits_then_child_process_should_also_do_so await parentIsRunning; // Start child - var childSettings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "dotnet") + var childSettings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = $"./NonTerminatingProcess/NonTerminatingProcess.dll --ParentProcessId={parentSupervisor.ProcessInfo.Id}" }; diff --git a/src/LittleForker.Tests/ProcessSupervisorTests.cs b/src/LittleForker.Tests/ProcessSupervisorTests.cs index d68e656..105ee92 100644 --- a/src/LittleForker.Tests/ProcessSupervisorTests.cs +++ b/src/LittleForker.Tests/ProcessSupervisorTests.cs @@ -22,7 +22,7 @@ public ProcessSupervisorTests(ITestOutputHelper outputHelper) [Fact] public async Task Given_invalid_process_path_then_state_should_be_StartError() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, "c:/", "invalid.exe"); + var settings = new ProcessSupervisorSettings("c:/", "invalid.exe"); var supervisor = new ProcessSupervisor(settings, _loggerFactory); var stateIsStartFailed = supervisor.WhenStateIs(ProcessSupervisor.State.StartFailed); await supervisor.Start(); @@ -37,7 +37,7 @@ public async Task Given_invalid_process_path_then_state_should_be_StartError() [Fact] public async Task Given_invalid_working_directory_then_state_should_be_StartError() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, "c:/does_not_exist", "git.exe"); + var settings = new ProcessSupervisorSettings("c:/does_not_exist", "git.exe"); var supervisor = new ProcessSupervisor(settings, _loggerFactory); await supervisor.Start(); @@ -51,7 +51,7 @@ public async Task Given_invalid_working_directory_then_state_should_be_StartErro public async Task Given_short_running_exe_then_should_run_to_exit() { var envVars = new StringDictionary {{"a", "b"}}; - var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./SelfTerminatingProcess/SelfTerminatingProcess.dll", EnvironmentVariables = envVars @@ -74,7 +74,7 @@ public async Task Given_short_running_exe_then_should_run_to_exit() [Fact] public async Task Given_non_terminating_process_then_should_exit_when_stopped() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" }; @@ -93,27 +93,10 @@ public async Task Given_non_terminating_process_then_should_exit_when_stopped() supervisor.ProcessInfo.ExitCode.ShouldBe(0); } - [Fact] - public async Task Can_restart_a_stopped_short_running_process() - { - var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, "dotnet") - { - Arguments = "./SelfTerminatingProcess/SelfTerminatingProcess.dll" - }; - var supervisor = new ProcessSupervisor(settings, _loggerFactory); - 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() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" }; @@ -134,7 +117,7 @@ public async Task Can_restart_a_stopped_long_running_process() [Fact] public async Task When_stop_a_non_terminating_process_without_a_timeout_then_should_exit_killed() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll" }; @@ -151,7 +134,7 @@ public async Task When_stop_a_non_terminating_process_without_a_timeout_then_sho [Fact] public async Task When_stop_a_non_terminating_process_that_does_not_shutdown_within_timeout_then_should_exit_killed() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll --ignore-shutdown-signal=true" }; @@ -168,7 +151,7 @@ public async Task When_stop_a_non_terminating_process_that_does_not_shutdown_wit [Fact] public async Task When_stop_a_non_terminating_process_with_non_zero_then_should_exit_error() { - var settings = new ProcessSupervisorSettings(ProcessRunType.NonTerminating, Environment.CurrentDirectory, "dotnet") + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "dotnet") { Arguments = "./NonTerminatingProcess/NonTerminatingProcess.dll --exit-with-non-zero=true" }; @@ -183,27 +166,10 @@ public async Task When_stop_a_non_terminating_process_with_non_zero_then_should_ _outputHelper.WriteLine($"Exit code {supervisor.ProcessInfo.ExitCode}"); } - [Fact] - public async Task Can_attempt_to_restart_a_failed_short_running_process() - { - var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, - "invalid.exe"); - var supervisor = new ProcessSupervisor(settings, _loggerFactory); - await supervisor.Start(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - - await supervisor.Start(); - - supervisor.CurrentState.ShouldBe(ProcessSupervisor.State.StartFailed); - supervisor.OnStartException.ShouldNotBeNull(); - } - [Fact] public void WriteDotGraph() { - var settings = new ProcessSupervisorSettings(ProcessRunType.SelfTerminating, Environment.CurrentDirectory, + var settings = new ProcessSupervisorSettings(Environment.CurrentDirectory, "invalid.exe"); var processController = new ProcessSupervisor(settings, _loggerFactory); _outputHelper.WriteLine(processController.GetDotGraph()); diff --git a/src/LittleForker/ProcessRunType.cs b/src/LittleForker/ProcessRunType.cs deleted file mode 100644 index 9151d1e..0000000 --- a/src/LittleForker/ProcessRunType.cs +++ /dev/null @@ -1,17 +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 terminate of their own - /// accord and that must be cooperatively shutdown or killed. - /// - NonTerminating -} \ No newline at end of file diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index cea81bb..7c8226c 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -6,32 +6,6 @@ namespace LittleForker; -public class ProcessSupervisorSettings -{ - public ProcessRunType ProcessRunType { get; } - 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( - ProcessRunType processRunType, - string workingDirectory, - string processPath) - { - ProcessRunType = processRunType; - WorkingDirectory = workingDirectory; - ProcessPath = processPath; - } -} - /// /// Launches an process and tracks it's lifecycle . /// @@ -119,26 +93,11 @@ public ProcessSupervisor( _processStateMachine .Configure(State.Running) .OnEntryFrom(Trigger.Start, OnStart) - .PermitIf( - Trigger.ProcessExit, - State.ExitedSuccessfully, - () => settings.ProcessRunType == ProcessRunType.SelfTerminating - && _process.HasExited - && _process.ExitCode == 0, - "SelfTerminating && ExitCode==0") - .PermitIf( - Trigger.ProcessExit, - State.ExitedWithError, - () => settings.ProcessRunType == ProcessRunType.SelfTerminating - && _process.HasExited - && _process.ExitCode != 0, - "SelfTerminating && ExitCode!=0") .PermitIf( Trigger.ProcessExit, State.ExitedUnexpectedly, - () => settings.ProcessRunType == ProcessRunType.NonTerminating - && _process.HasExited, - "NonTerminating and died.") + () => _process.HasExited, + "NonTerminating but exited.") .Permit(Trigger.Stop, State.Stopping) .Permit(Trigger.StartError, State.StartFailed); @@ -150,20 +109,17 @@ public ProcessSupervisor( .Configure(State.Stopping) .OnEntryFromAsync(_stopTrigger, OnStop) .PermitIf(Trigger.ProcessExit, State.ExitedSuccessfully, - () => settings.ProcessRunType == ProcessRunType.NonTerminating - && !_killed + () => !_killed && _process.HasExited && _process.ExitCode == 0, "NonTerminating and shut down cleanly") .PermitIf(Trigger.ProcessExit, State.ExitedWithError, - () => settings.ProcessRunType == ProcessRunType.NonTerminating - && !_killed + () => !_killed && _process.HasExited && _process.ExitCode != 0, "NonTerminating and shut down with non-zero exit code") .PermitIf(Trigger.ProcessExit, State.ExitedKilled, - () => settings.ProcessRunType == ProcessRunType.NonTerminating - && _killed + () => _killed && _process.HasExited && _process.ExitCode != 0, "NonTerminating and killed."); 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/SelfTerminatingProcess/Program.cs b/src/SelfTerminatingProcess/Program.cs deleted file mode 100644 index 9228d66..0000000 --- a/src/SelfTerminatingProcess/Program.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System; - -Console.WriteLine("Done."); diff --git a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj b/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj deleted file mode 100644 index bb0e294..0000000 --- a/src/SelfTerminatingProcess/SelfTerminatingProcess.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net6.0 - preview - 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 From 68fc3c575b2f4d020dd659debc5e29363fb0da7e Mon Sep 17 00:00:00 2001 From: Damian Hickey Date: Sun, 21 May 2023 13:47:46 +0200 Subject: [PATCH 9/9] wip --- .../CooperativeShutdownTests.cs | 42 +++++++++++++++---- src/LittleForker/CooperativeShutdown.cs | 9 +++- src/LittleForker/ProcessSupervisor.cs | 9 ++-- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/LittleForker.Tests/CooperativeShutdownTests.cs b/src/LittleForker.Tests/CooperativeShutdownTests.cs index d516999..f25db1d 100644 --- a/src/LittleForker.Tests/CooperativeShutdownTests.cs +++ b/src/LittleForker.Tests/CooperativeShutdownTests.cs @@ -1,8 +1,9 @@ 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; @@ -20,15 +21,38 @@ public CooperativeShutdownTests(ITestOutputHelper outputHelper) [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); + var applicationLifetime = new FakeHostApplicationLifetime(); + var options = new CooperativeShutdownHostedServiceOptions() + { + PipeName = Guid.NewGuid().ToString() + }; + var cooperativeShutdownHostedService = new CooperativeShutdownHostedService( + applicationLifetime, + Options.Create(options), + _loggerFactory.CreateLogger()); - await CooperativeShutdown.SignalExit(Process.GetCurrentProcess().Id, _loggerFactory); + await cooperativeShutdownHostedService.StartAsync(CancellationToken.None); - (await exitCalled.Task).ShouldBeTrue(); + await CooperativeShutdown.SignalExit(options.PipeName, _loggerFactory); - listener.Dispose(); + await applicationLifetime.StopApplicationCalled.TimeoutAfter(TimeSpan.FromSeconds(5)); + + 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/CooperativeShutdown.cs b/src/LittleForker/CooperativeShutdown.cs index 50d0b9c..d433df0 100644 --- a/src/LittleForker/CooperativeShutdown.cs +++ b/src/LittleForker/CooperativeShutdown.cs @@ -64,10 +64,15 @@ public static Task Listen(Action shutdownRequested, ILoggerFactory /// 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) + public static Task SignalExit(int processId, ILoggerFactory loggerFactory) { - var logger = loggerFactory.CreateLogger($"{nameof(LittleForker)}.{nameof(CooperativeShutdown)}"); 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)) { try diff --git a/src/LittleForker/ProcessSupervisor.cs b/src/LittleForker/ProcessSupervisor.cs index 7c8226c..6d9cf7d 100644 --- a/src/LittleForker/ProcessSupervisor.cs +++ b/src/LittleForker/ProcessSupervisor.cs @@ -222,11 +222,14 @@ private void OnStart() }; // Copy over environment variables - foreach (string key in _environmentVariables.Keys) + if (_environmentVariables != null) { - processStartInfo.EnvironmentVariables[key] = _environmentVariables[key]; + foreach (string key in _environmentVariables.Keys) + { + processStartInfo.EnvironmentVariables[key] = _environmentVariables[key]; + } } - + // Start the process and capture it's output. _process = new Process