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
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
the actions are never run at the same time: each one returns before the other is called, so both may share state, such as one list collecting all of a command's output, without locking.
The promise only holds while the run finishes normally. When the token is cancelled, DrainOrAbandon returns as soon as cancelled wins (AsyncProcessStreamReader.cs:100-106). That abandons any delivery already inside onData, and nothing waits for deliveryLock before Start returns. ExecuteAsync then disposes the reader and throws OperationCanceledException (RunCommand.cs:490-508) while the delegate is still running. deliveryLock belongs to the reader, so it only serialises deliveries within one run (AsyncProcessStreamReader.cs:162-165).
The fault branch has the same gap: when one stream's delegate throws, the other stream's in-flight delivery isn't waited for either.
Repro (Linux, net10.0, current main)
intcount=0,active=0,maxActive=0;voidEnter(){inta=Interlocked.Increment(refactive);maxActive=Math.Max(maxActive,a);}voidExit()=>Interlocked.Decrement(refactive);varhandler=newOutputHandler(
s =>{Enter();if(++count==2)Thread.Sleep(1500);Exit();},// slow on its 2nd chunk
s =>{Enter();Thread.Sleep(50);Exit();});using(varcts=newCancellationTokenSource(300))try{awaitRunCommand.ExecuteAsync("sh",["-c","echo run1a; sleep 0.1; echo run1b; sleep 100"],handler,cts.Token);}catch(OperationCanceledException){Console.WriteLine($"run 1 threw OCE; delegates active now = {active}");}awaitRunCommand.ExecuteAsync("sh",["-c","for i in 1 2 3 4 5; do echo run2-$i >&2; sleep 0.1; done"],handler);Console.WriteLine($"max concurrent delegates = {maxActive}");
Actual:
[ 124ms] enter stdout run1b (active=1)
[ 347ms] run 1 threw OCE; delegates active now = 1
[ 351ms] enter stderr run2-1 (active=2)
[ 861ms] run 2 exit 0; max concurrent delegates = 2
[ 1624ms] exit stdout run1b
Expected: ExecuteAsync throws only after the running delegate returns. No delegate from that run starts after the throw, and maxActive is 1.
Why it matters
Within one run: the caller's catch/finally, which typically reads or disposes the shared list or writer the delegates fill, races a delegate that is still writing to it.
Add an abandoned flag to AsyncProcessStreamReader.
In DrainOrAbandon's cancel and fault branches, run lock (deliveryLock) { abandoned = true; } before returning or rethrowing. This waits for an in-flight delivery to finish.
In ReadToEnd, check if (abandoned) return; inside the lock before calling onData.
The wait is bounded by the caller's own delegate, not by a pipe, so the #78 guarantee is unaffected. The fix may also let the "finishes into the old buffer" workaround in LineOutputHandler.Reset (#93) go.
Acceptance criteria: a test with a slow stdout delegate and a cancelled token asserts that no delegate is running when ExecuteAsync throws and that none starts afterwards. A second test asserts the same for the fault path, where the stdout delegate throws while a stderr delegate is mid-call.
Area / suggested owner:AsyncProcessStreamReader (DrainOrAbandon cancel and fault branches, ReadToEnd delivery under deliveryLock) and the cancellation path of RunCommand.ExecuteAsync. Owner: @matt-edmondson
Activity
matt-edmondson commentedon Oct 29, 2026
Triage
OutputHandlerguarantee from 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 ("the actions are never run at the same time"). On the cancel and fault paths, a caller'scatch/finally, or the next run on a reused handler, can race a delegate that is still writing shared state. Lines can be lost or a collection corrupted, with no error. The repro is deterministic.AsyncProcessStreamReader(DrainOrAbandoncancel and fault branches,ReadToEnddelivery underdeliveryLock) and the cancellation path ofRunCommand.ExecuteAsync. Owner: @matt-edmondsonabandonedflag, set underdeliveryLockin both branches ofDrainOrAbandon.LineOutputHandler.Reset(A reused LineOutputHandler prepends a cancelled run's partial line to the next run's first line ("partial" + "hello" → "partialhello") #93) can then be removed.Generated by Claude Code