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
Standard output and standard error delegates run concurrently on two threads, so a shared collection silently loses lines (~0.5% of 40,000 lost every run) #95
AsyncProcessStreamReader.Start starts two independent ReadToEnd loops, one for stdout and one for stderr (RunCommand/AsyncProcessStreamReader.cs, outputTask/errorTask). Each loop calls its handler delegate directly from its own read continuation, so onStandardOutput and onStandardError can run at the same time on different thread-pool threads. Nothing in the library serialises them, and nothing in the README or XML docs warns about it.
The most natural way to capture a command's full output hits this directly:
List<T>.Add isn't thread-safe. Concurrent calls overwrite each other's slots, so lines disappear with no exception. The same goes for a shared StringBuilder, or any caller state that isn't synchronised. LineOutputHandler keeps a separate buffer per stream, so its own state is fine. The race is in the caller's delegates.
(The test suite already works around this: RunCommandTests.cs takes lock (output) inside its delegates. Library users get no such hint.)
Reproduction (net10.0, Linux, current main @ 73de151)
usingktsu.RunCommand;List<string>lines=new();intactive=0,overlap=0;voidAdd(stringl){if(Interlocked.Increment(refactive)>1)Interlocked.Increment(refoverlap);lines.Add(l);Thread.SpinWait(50);Interlocked.Decrement(refactive);}awaitRunCommand.ExecuteAsync("sh",new[]{"-c","for i in $(seq 1 20000); do echo out$i; echo err$i >&2; done"},newLineOutputHandler(Add,Add));Console.WriteLine($"{lines.Count} lines (expected 40000), overlapping callbacks {overlap}");
Result: all 20 of 20 runs lost lines, between 18 and 553 per run (e.g. 39447 lines (expected 40000), overlapping callbacks 27911). The overlap counter shows the two delegates running inside each other tens of thousands of times per run. The SpinWait only widens the window, since the counter shows the overlap happening anyway.
In a worse case, the concurrent List<T>.Add can also throw (e.g. IndexOutOfRangeException during a resize). That exception comes out of the reader task, which lands on the hang described in #89.
Suggested fix
Pick one of these and pin it with a test:
Serialise delivery (preferred). Take one lock (per OutputHandler or per run) around each onData invocation in ReadToEnd, so stdout and stderr delegates never run at the same time. The reads can still run concurrently, and only delivery is serialised. The cost is small next to a pipe read. Callers can then share state between the two delegates safely, which is what the README examples (Console.Write for both) already suggest.
Or document the contract. State in the OutputHandler/LineOutputHandler XML docs and the README that the two delegates may run at the same time on different threads, and that shared state needs synchronisation.
Acceptance criteria
A test that runs a command interleaving many stdout/stderr lines into a single non-thread-safe List<string> via both delegates gets every line back, repeated enough times to be reliable (the repro above fails every run today). Alternatively, the documentation states the concurrent-invocation contract explicitly.
Suggested next step: take option 1 (serialise delivery with one lock per run around each onData call). It's a few lines, the reads stay concurrent, and it makes the README's Console.Write-for-both examples correct as written. Documenting the contract instead would leave every existing consumer to find the race on their own. Once it lands, check whether KtsuBuild's and GitBranchStateCache's handlers carry their own locks that could be removed.
Activity
matt-edmondson commentedon Sep 28, 2026
Triage
StringBuildercan drop build or git output without knowing.RunCommand/AsyncProcessStreamReader.cs(Start/ReadToEnd), plus theOutputHandler/LineOutputHandlerdocs and README.List<T>.Addthrowing is one way to reach that hang, but the root causes are different.Suggested next step: take option 1 (serialise delivery with one lock per run around each
onDatacall). It's a few lines, the reads stay concurrent, and it makes the README'sConsole.Write-for-both examples correct as written. Documenting the contract instead would leave every existing consumer to find the race on their own. Once it lands, check whether KtsuBuild's and GitBranchStateCache's handlers carry their own locks that could be removed.Generated by Claude Code