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
NativeCommandExecutor's synchronous path normalizes line endings and appends a trailing newline; the async path does not #23
NativeCommandExecutor has two capture paths that disagree about the bytes of the child's output.
ExecuteAsync reads raw text, preserving exactly what the child wrote (NativeCommandExecutor.cs:59-63, StandardOutput.ReadToEndAsync(...)).
The synchronous primitive added in 223131f captures line by line through OutputDataReceived/ErrorDataReceived (NativeCommandExecutor.cs:126-127) and reassembles with:
// NativeCommandExecutor.cs:196-202privatestaticvoidAppendLine(StringBuilderbuffer,string?line){if(lineis not null){buffer.AppendLine(line);}}
StringBuilder.AppendLine(string) appends Environment.NewLine after every line, including the last one — and the Process line splitter has already discarded the child's own terminators.
Failure scenario
On Linux, running /bin/sh -c "printf 'a\r\nb'":
call
StandardOutput
ExecuteAsync(cmd).Result
"a\r\nb"
Execute(cmd)
"a\nb\n"
Simplest case — printf hello: async yields "hello", sync yields "hello\n". On Windows a child emitting bare \n has every newline rewritten to \r\n.
Why it matters
ICommandExecutor.cs:84-93 documents the synchronous overload as the primitive "every other synchronous member composes over", which implies it agrees with the asynchronous path on the result. The commit that introduced it (223131f, closing #17) frames the change purely as an exception-wrapping and thread-blocking fix, not a behavioural one.
Callers that compare output exactly, hash it, or parse output that is not line-oriented — git cat-file, base64 -w0, anything binary-ish — silently get different data depending on which overload they used. ExecuteAndGetOutput is affected too, since it composes over the same primitive.
Missing coverage
Every existing assertion uses .Trim().Contains(...) — for example Essentials.Tests/CommandExecutorTests.cs:38-39 — so no current test can observe this. There is no test asserting that the two paths agree on StandardOutput for the same command.
Suggested fix
Capture raw in the synchronous path as well (a dedicated reader thread per stream, or StandardOutput.ReadToEnd() on one stream while a background thread drains the other). Failing that, document the normalization explicitly on Execute.
Either way, add a test pinning sync-vs-async agreement on a command whose output has no trailing newline.
Not a duplicate of #17, which covered AggregateException wrapping, pool-thread blocking, and the hard-coded CancellationToken.None.
What needs to be done:NativeCommandExecutor's synchronous Execute primitive reassembles output line-by-line via StringBuilder.AppendLine, which normalizes line endings to Environment.NewLine and appends a trailing newline — while the asynchronous path reads raw text unchanged. The two disagree on the exact bytes of StandardOutput/StandardError for the same command, despite the synchronous primitive being documented as the one every other synchronous member composes over.
Suggested next steps / acceptance criteria:
Capture output raw in the synchronous path too — e.g. a dedicated reader thread per stream doing ReadToEnd(), rather than the line-splitting OutputDataReceived/ErrorDataReceived callbacks — so both paths agree byte-for-byte.
Add a test asserting sync/async agreement on a command whose output has no trailing newline and uses non-native line endings (e.g. embedded \r\n on Linux).
Blockers / dependencies: None identified; not a duplicate of #17 (that covered exception wrapping, thread blocking, and the hard-coded cancellation token — this is a separate behavioural divergence).
Activity
matt-edmondson commentedon Sep 11, 2026
Triage
Priority: Medium Effort: Medium
What needs to be done:
NativeCommandExecutor's synchronousExecuteprimitive reassembles output line-by-line viaStringBuilder.AppendLine, which normalizes line endings toEnvironment.NewLineand appends a trailing newline — while the asynchronous path reads raw text unchanged. The two disagree on the exact bytes ofStandardOutput/StandardErrorfor the same command, despite the synchronous primitive being documented as the one every other synchronous member composes over.Suggested next steps / acceptance criteria:
ReadToEnd(), rather than the line-splittingOutputDataReceived/ErrorDataReceivedcallbacks — so both paths agree byte-for-byte.Execute's XML doc instead.\r\non Linux).Blockers / dependencies: None identified; not a duplicate of #17 (that covered exception wrapping, thread blocking, and the hard-coded cancellation token — this is a separate behavioural divergence).
Generated by Claude Code