You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
One LineOutputHandler shared by two concurrent ExecuteAsync runs drops one run's partial line and glues the two runs' output into one line #130
A LineOutputHandler keeps its line-splitting state in instance fields, outputBuffer and errorBuffer (RunCommand/LineOutputHandler.cs:20,26). RunAsync calls outputHandler.Reset() at the start of every run (RunCommand/RunCommand.cs:465), and Reset() swaps in fresh buffers (LineOutputHandler.cs:74-78).
When two runs overlap on the same handler:
Run B's Reset() throws away the partial line run A had buffered.
Both runs then append to the same StringBuilder, so text from A and text from B end up joined into one line.
Each run has its own deliveryLock, so nothing serializes the two runs. Two threads modify the same StringBuilder at once, which is a data race that can corrupt the buffer or throw. It also breaks the promise in OutputHandler.cs:12-14 that the delegates are never run at the same time.
Expected:"AAAAAAAA" | "BBBBBBBB", in either order.
Why it matters
Passing one logging handler to several commands run under Task.WhenAll (parallel builds, fan-out tooling) is a natural pattern. The README and the comments describe handlers as reusable, and nothing says they serve one run at a time. The result is output that is silently lost or garbled, and possibly an exception from inside the StringBuilder.
Preferred: move the line-splitting state into a per-run object. For example, RunAsync creates an internal LineSplitter for each run, and chunks pass through it before the user's delegates are called. The handler then holds no per-run state, and Reset() is no longer needed.
Cheaper alternative: detect overlapping use with an Interlocked in-use flag, throw InvalidOperationException, and document that a handler serves one run at a time.
Acceptance criteria
With the per-run fix, the repro above delivers AAAAAAAA and BBBBBBBB intact, and a test covers two overlapping runs on one handler.
With the alternative fix, the second overlapping run throws a clear exception, and the limitation is documented.
Priority: High. Two runs that share one handler modify the same StringBuilder at once, with no lock between them. Output is lost or garbled without any warning, StringBuilder can throw, and the "delegates never run concurrently" contract in OutputHandler.cs is broken. Sharing one logging handler across a Task.WhenAll fan-out is a common pattern.
Area / suggested owner:LineOutputHandler (per-run buffer state) and RunCommand.RunAsync, where Reset() is called at the start of each run.
Next step: take the per-run splitter fix rather than the in-use guard, unless the API should state "one run per handler" outright. Add the overlapping-runs test from the repro.
Activity
matt-edmondson commentedon Oct 31, 2026
Triage
StringBuilderat once, with no lock between them. Output is lost or garbled without any warning,StringBuildercan throw, and the "delegates never run concurrently" contract inOutputHandler.csis broken. Sharing one logging handler across aTask.WhenAllfan-out is a common pattern.LineOutputHandler(per-run buffer state) andRunCommand.RunAsync, whereReset()is called at the start of each run.LineSplitter, moves all per-run state out of the handler and should be checked against A cancelled ExecuteAsync throws while an output delegate is still running, so the caller's catch block (or the next run on the same handler) runs at the same time as it: a gap in the #95 fix #125 at the same time.LineOutputHandlerbuffering, so whichever of the two lands second should rebase on the first.Next step: take the per-run splitter fix rather than the in-use guard, unless the API should state "one run per handler" outright. Add the overlapping-runs test from the repro.
Generated by Claude Code