From 1e4d0287bf19f82c7179eb3b6fbdde0e6bb6c9a5 Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Sun, 27 Sep 2026 01:26:34 +0000 Subject: [PATCH] Deliver a final unterminated line when a LineOutputHandler run ends LineOutputHandler only raised text that ended in a line break, and nothing flushed its buffers when the process exited. A command whose last line had no terminator lost that line, and a reused handler glued the leftover onto the first line of its next run. OutputHandler gains an internal Complete() hook, which RunAsync calls once both streams are drained and the run was not cancelled. LineOutputHandler overrides it to deliver any buffered partial line (a held-back trailing CR counts as the line break) and clear both buffers. Fixes #88 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qSqApi9HSiBjyHaJQYPTP --- RunCommand.Test/LineOutputHandlerTests.cs | 69 +++++++++++++++++++++++ RunCommand.Test/RunCommandTests.cs | 41 ++++++++++++++ RunCommand/LineOutputHandler.cs | 31 ++++++++++ RunCommand/OutputHandler.cs | 6 ++ RunCommand/RunCommand.cs | 7 +++ 5 files changed, 154 insertions(+) diff --git a/RunCommand.Test/LineOutputHandlerTests.cs b/RunCommand.Test/LineOutputHandlerTests.cs index 46f2e94..3b397c8 100644 --- a/RunCommand.Test/LineOutputHandlerTests.cs +++ b/RunCommand.Test/LineOutputHandlerTests.cs @@ -7,6 +7,9 @@ public class LineOutputHandlerTests { private static readonly string[] ExpectedXY = ["x", "y"]; private static readonly string[] ExpectedEveryLineEndingKind = ["a", "b", "c", "d", ""]; + private static readonly string[] ExpectedALast = ["a", "last"]; + private static readonly string[] ExpectedALastNext = ["a", "last", "next"]; + private static readonly string[] ExpectedOops = ["oops"]; [TestMethod] public void HandleStandardOutputDataShouldProcessLinesCorrectly() @@ -159,4 +162,70 @@ public void HandleStandardOutputDataShouldSplitOnEveryLineEndingKind() Assert.AreSequenceEqual(ExpectedEveryLineEndingKind, lines); Assert.AreEqual("e", handler.outputBuffer); } + + [TestMethod] + public void CompleteShouldDeliverTheFinalUnterminatedLine() + { + // Arrange + List output = []; + List error = []; + LineOutputHandler handler = new(onStandardOutput: output.Add, onStandardError: error.Add); + handler.HandleStandardOutputData("a\nlast"); + handler.HandleStandardErrorData("oops"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, output); + Assert.AreSequenceEqual(ExpectedOops, error); + Assert.AreEqual("", handler.outputBuffer); + Assert.AreEqual("", handler.errorBuffer); + } + + [TestMethod] + public void CompleteShouldNotEmitAnExtraLineWhenOutputEndsOnALineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\nlast\n"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, lines); + } + + [TestMethod] + public void CompleteShouldTreatATrailingCarriageReturnAsALineBreak() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\rlast\r"); + + // Act + handler.Complete(); + + // Assert + Assert.AreSequenceEqual(ExpectedALast, lines); + } + + [TestMethod] + public void CompleteShouldKeepAPartialLineFromLeakingIntoTheNextRun() + { + // Arrange + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + handler.HandleStandardOutputData("a\nlast"); + handler.Complete(); + + // Act + handler.HandleStandardOutputData("next\n"); + + // Assert + Assert.AreSequenceEqual(ExpectedALastNext, lines); + } } diff --git a/RunCommand.Test/RunCommandTests.cs b/RunCommand.Test/RunCommandTests.cs index c5090f7..3a34051 100644 --- a/RunCommand.Test/RunCommandTests.cs +++ b/RunCommand.Test/RunCommandTests.cs @@ -11,6 +11,9 @@ namespace ktsu.RunCommand.Test; [TestClass] public class RunCommandTests { + private static readonly string[] ExpectedALast = ["a", "last"]; + private static readonly string[] ExpectedALastNext = ["a", "last", "next"]; + private static string GetCopyCommand(string source, string destination) => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"cmd /c copy \"{source}\" \"{destination}\"" @@ -967,4 +970,42 @@ public async Task ExecuteAsyncShouldDeliverOutputStillInThePipeWhenTheProcessExi File.Delete(path); } } + + /// + /// Returns a command that prints a file's contents unchanged. + /// + private static (string FileName, string[] Arguments) GetPrintFileCommand(string path) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? ("cmd", ["/c", "type", path]) + : ("cat", [path]); + + [TestMethod] + public async Task LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun() + { + string first = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.1.txt"); + string second = Path.Join(Path.GetTempPath(), $"{nameof(RunCommandTests)}.{nameof(LineOutputHandlerShouldDeliverAFinalLineWithoutALineBreakAndNotCarryItIntoTheNextRun)}.2.txt"); + await File.WriteAllTextAsync(first, "a\nlast").ConfigureAwait(false); + await File.WriteAllTextAsync(second, "next\n").ConfigureAwait(false); + + try + { + List lines = []; + LineOutputHandler handler = new(onStandardOutput: lines.Add); + + (string fileName, string[] arguments) = GetPrintFileCommand(first); + int exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false); + Assert.AreEqual(0, exitCode); + Assert.AreSequenceEqual(ExpectedALast, lines); + + (fileName, arguments) = GetPrintFileCommand(second); + exitCode = await RunCommand.ExecuteAsync(fileName, arguments, handler).ConfigureAwait(false); + Assert.AreEqual(0, exitCode); + Assert.AreSequenceEqual(ExpectedALastNext, lines); + } + finally + { + File.Delete(first); + File.Delete(second); + } + } } diff --git a/RunCommand/LineOutputHandler.cs b/RunCommand/LineOutputHandler.cs index c8044ac..d9b4aab 100644 --- a/RunCommand/LineOutputHandler.cs +++ b/RunCommand/LineOutputHandler.cs @@ -52,6 +52,37 @@ internal override void HandleStandardErrorData(string data) ProcessDataByLine(data, ref errorBuffer, OnStandardError); } + /// + /// Delivers the text left in each buffer as a final line, since no line break will now arrive to end it, + /// and leaves both buffers empty so a later run on this handler starts clean. + /// + internal override void Complete() + { + FlushBuffer(ref outputBuffer, OnStandardOutput); + FlushBuffer(ref errorBuffer, OnStandardError); + } + + /// + /// Invokes with the buffered final line, if there is one, and clears the buffer. + /// + /// The buffer holding an incomplete line. + /// The action to be invoked for the final line. + /// + /// A buffer that ends in a CR is a line whose break had not yet been confirmed as CR or CRLF. + /// At the end of the stream it is a CR on its own, so it ends the line rather than being part of it. + /// + private static void FlushBuffer(ref string buffer, Action? onLineReceived) + { + if (buffer.Length == 0) + { + return; + } + + string line = buffer[^1] == '\r' ? buffer[..^1] : buffer; + buffer = ""; + onLineReceived?.Invoke(line); + } + /// /// Processes the data by line, invoking the specified action for each line received. /// diff --git a/RunCommand/OutputHandler.cs b/RunCommand/OutputHandler.cs index 5647ff9..4e63a9f 100644 --- a/RunCommand/OutputHandler.cs +++ b/RunCommand/OutputHandler.cs @@ -60,4 +60,10 @@ internal virtual void HandleStandardErrorData(string data) Ensure.NotNull(data); OnStandardError?.Invoke(data); } + + /// + /// Called once both output streams have been read to their end and the process has exited, + /// so a handler that holds back partial data can deliver it. Not called when the run is cancelled. + /// + internal virtual void Complete() { } } diff --git a/RunCommand/RunCommand.cs b/RunCommand/RunCommand.cs index 3ea6e54..e3da840 100644 --- a/RunCommand/RunCommand.cs +++ b/RunCommand/RunCommand.cs @@ -492,6 +492,13 @@ private static async Task RunAsync(ProcessStartInfo startInfo, OutputHandle // never mistaken for a command that genuinely failed. cancellationToken.ThrowIfCancellationRequested(); + if (!useElevation) + { + // Both streams have been read to their end, so anything a handler is still holding back + // waiting for a line break will never get one. + outputHandler.Complete(); + } + return process.ExitCode; }