diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index 818e08c..59b54ec 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -1216,4 +1216,117 @@ public async Task LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNo File.Delete(second); } } + + [TestMethod] + public async Task ReusedLineOutputHandlerShouldDropTheLineACancelledRunLeftUnfinished() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it."); + } + + List lines = []; + using SemaphoreSlim partialArrived = new(0); + LineOutputHandler handler = new(lines.Add); + + using (CancellationTokenSource cancellationTokenSource = new()) + { + Task cancelled = RunCommand.ExecuteAsync( + "sh", + ["-c", "printf partial; sleep 30"], + new SignallingLineOutputHandler(handler, partialArrived), + cancellationTokenSource.Token); + + Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive."); + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); + await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false); + } + + Assert.AreEqual("partial", handler.outputBuffer.ToString(), "The cancelled run should have left its partial line buffered."); + + int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the cancelled run's leftover text."); + } + + [TestMethod] + public async Task ReusedLineOutputHandlerShouldDropTheLineARunWhoseCallbackThrewLeftUnfinished() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("Needs sh to print a line without a line break. The reset this covers is in platform independent code, so the other legs cover it."); + } + + List lines = []; + bool throwOnError = true; + LineOutputHandler handler = new( + lines.Add, + line => + { + if (throwOnError) + { + throw new InvalidOperationException("handler failed"); + } + }); + + // Standard output's partial line is buffered before standard error's line makes the callback throw. + await Assert.ThrowsAsync( + () => RunCommand.ExecuteAsync("sh", ["-c", "printf partial; sleep 0.5; echo boom >&2; sleep 30"], handler)).ConfigureAwait(false); + + Assert.AreEqual("partial", handler.outputBuffer.ToString(), "The failed run should have left its partial line buffered."); + + throwOnError = false; + int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], handler).ConfigureAwait(false); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual("hello", string.Join(" | ", lines), "The next run's first line should not carry the failed run's leftover text."); + } + + [TestMethod] + public async Task AbandonedReaderShouldNotDeliverIntoALaterRunOnTheSameHandler() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.Inconclusive("Needs a shell that can orphan a child out of its own process tree while that child keeps the pipe it inherited. That a cancelled run's reads stop delivering is platform independent, so the other legs cover it."); + } + + List lines = []; + using SemaphoreSlim partialArrived = new(0); + LineOutputHandler handler = new(lines.Add); + + // The orphaned subshell escapes the kill and keeps the cancelled run's standard output open, then + // writes to it a second later, while the next run on the same handler is still going. The + // cancelled run gave up on that read, so what arrives there must not reach the handler. + using (CancellationTokenSource cancellationTokenSource = new()) + { + Task cancelled = RunCommand.ExecuteAsync( + "sh", + ["-c", "sh -c '(sleep 1; printf late) &'; printf partial; sleep 30"], + new SignallingLineOutputHandler(handler, partialArrived), + cancellationTokenSource.Token); + + Assert.IsTrue(await partialArrived.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false), "Expected the partial line to arrive."); + await cancellationTokenSource.CancelAsync().ConfigureAwait(false); + await Assert.ThrowsAsync(() => cancelled).ConfigureAwait(false); + } + + int exitCode = await RunCommand.ExecuteAsync("sh", ["-c", "sleep 2; echo hello"], handler).ConfigureAwait(false); + + Assert.AreEqual(0, exitCode); + Assert.AreEqual("hello", string.Join(" | ", lines), "Output the cancelled run's abandoned reader received should not reach the later run."); + } + + /// + /// Forwards standard output to and signals after each chunk, + /// so a test can wait for output to arrive before it cancels. + /// + private sealed class SignallingLineOutputHandler(LineOutputHandler inner, SemaphoreSlim received) : OutputHandler + { + internal override void HandleStandardOutputData(string data) + { + inner.HandleStandardOutputData(data); + received.Release(); + } + } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index 700b9f5..d04223a 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -13,12 +13,17 @@ public class LineOutputHandler : OutputHandler /// /// Buffer to store incomplete lines from standard output. /// - internal readonly StringBuilder outputBuffer = new(); + /// + /// Replaced rather than cleared by , so a delivery still in progress from a + /// cancelled run finishes into the old buffer instead of the new run's. + /// + internal StringBuilder outputBuffer = new(); /// /// Buffer to store incomplete lines from standard error. /// - internal readonly StringBuilder errorBuffer = new(); + /// See . + internal StringBuilder errorBuffer = new(); /// /// Initializes a new instance of the class. @@ -62,6 +67,16 @@ internal override void Complete() FlushBuffer(errorBuffer, OnStandardError); } + /// + /// Discards any partial line, including a CR still waiting to see whether an LF follows, that a + /// cancelled or failed run left in the buffers. + /// + internal override void Reset() + { + outputBuffer = new(); + errorBuffer = new(); + } + /// /// Invokes with the buffered final line, if there is one, and clears the buffer. /// diff --git a/RunCommand/OutputHandler.cs b/RunCommand/OutputHandler.cs index 4e63a9f..6d75258 100644 --- a/RunCommand/OutputHandler.cs +++ b/RunCommand/OutputHandler.cs @@ -66,4 +66,11 @@ internal virtual void HandleStandardErrorData(string data) /// so a handler that holds back partial data can deliver it. Not called when the run is cancelled. /// internal virtual void Complete() { } + + /// + /// Called at the start of every run, so a handler that holds back partial data can discard what an + /// earlier run left behind. A run that was cancelled or failed never reaches , + /// and its leftover text is not a real line, so it is dropped rather than delivered. + /// + internal virtual void Reset() { } } diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 2440dc5..8ed9571 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -460,6 +460,10 @@ private static async Task RunAsync(ProcessStartInfo startInfo, OutputHandle { cancellationToken.ThrowIfCancellationRequested(); + // A handler can be reused, and a run that was cancelled or failed never reached Complete, so + // whatever partial line it left behind would otherwise be glued onto this run's first line. + outputHandler.Reset(); + using Process process = new() { StartInfo = startInfo }; process.Start();