Skip to content

A reused LineOutputHandler prepends a cancelled run's partial line to the next run's first line ("partial" + "hello" → "partialhello") #93

Description

@matt-edmondson

What's wrong

LineOutputHandler holds unterminated text in outputBuffer / errorBuffer (RunCommand/LineOutputHandler.cs:16-21). The only thing that clears them is Complete() (LineOutputHandler.cs:57-63), and its doc comment says it "leaves both buffers empty so a later run on this handler starts clean". Reusing a handler is therefore supported.

RunAsync calls Complete() only on the success path (RunCommand/RunCommand.cs:493-500). When a run is cancelled, cancellationToken.ThrowIfCancellationRequested() throws first, and the buffers keep the partial line. A run that faults, for example because a callback throws, keeps them too. Nothing resets the buffers when the next run starts, so the leftover text is glued onto that run's first line.

There's also a thread-safety problem. If a cancelled run's readers were abandoned because a descendant still holds the pipe, those readers can keep appending to the same unsynchronized buffers while the next run is using them.

Repro (verified on Linux, net10.0, at 12afce7)

var lines = new List<string>();
var h = new LineOutputHandler(l => lines.Add(l));

using (var cts = new CancellationTokenSource(1500))
{
    try { await RunCommand.ExecuteAsync("sh", ["-c", "printf partial; sleep 10"], h, cts.Token); }
    catch (OperationCanceledException) { }
}

await RunCommand.ExecuteAsync("sh", ["-c", "echo hello"], h);
// observed: lines == ["partialhello"]
// expected: lines == ["hello"]

Why it matters

A common pattern is to keep one handler, often a field, that forwards lines to a logger or UI, and run commands through it with a timeout or a cancel button. After any cancelled or failed run, the next command's first line is corrupted. A caller that parses output, such as git rev-parse or a version probe, gets a wrong value and no error.

Suggested fix

  • Reset handler state at the start of each run: add an internal virtual void Reset() on OutputHandler, override it in LineOutputHandler to clear both buffers and any pending-CR state, and call it from RunAsync before Start. Alternatively, clear the buffers in a finally when a run does not complete normally. Either way, discard the leftover text rather than flushing it, because a cancelled run's partial line isn't a real line.
  • An abandoned reader from a cancelled run must not be able to write into the buffers of a later run. For example, give each run its own buffer state, or have the reader check a per-run generation token.

Acceptance criteria

  • The repro above yields ["hello"].
  • A test covers reusing a handler after a cancelled run, and another covers reuse after a run whose callback threw.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreadyFully specified; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions