fix: capture raw output in the synchronous command path [patch] - #27
Merged
Merged
Conversation
NativeCommandExecutor's synchronous primitive reassembled output from the line-splitting OutputDataReceived/ErrorDataReceived callbacks with StringBuilder.AppendLine, which rewrote the child's line terminators as Environment.NewLine and appended one it never wrote. ExecuteAsync reads the streams raw, so the two paths disagreed on the exact text for the same command: `printf 'a\r\nb'` yielded "a\r\nb" asynchronously and "a\nb\n" synchronously. Each redirected stream is now read to the end by its own dedicated thread. That keeps the property the callbacks were chosen for - neither stream can fill its buffer while the other is being read, and the calling thread stays free to poll the cancellation token - without borrowing a pool thread, which is the cost the synchronous primitive exists to avoid. Adds a test pinning sync/async agreement on a command whose output ends without a terminator and embeds a foreign one, which no existing assertion could observe because they all compare with Trim().Contains(...). Fixes #23 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28xwrsRuiwGs84jahEDh3
MSTEST0049: the synchronous overload in the new sync/async agreement test took the default token while the asynchronous call beside it already passed TestContext.CancellationToken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R28xwrsRuiwGs84jahEDh3
|
This was referenced Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Fixes #23
The problem
NativeCommandExecutorhas two capture paths that disagreed about the bytes of the child's output.ExecuteAsyncreads the redirected streams raw, preserving exactly what the child wrote. The synchronous primitive captured line by line throughOutputDataReceived/ErrorDataReceivedand reassembled withStringBuilder.AppendLine, which appendsEnvironment.NewLineafter every line — including the last — after theProcessline splitter has already discarded the child's own terminators.On Linux,
printf 'a\r\nb':StandardOutputExecuteAsync(cmd).Result"a\r\nb"Execute(cmd)"a\nb\n"ICommandExecutordocuments the synchronous overload as the primitive "every other synchronous member composes over", which implies it agrees with the asynchronous path. Callers that compare output exactly, hash it, or parse output that is not line-oriented silently got different data depending on which overload they used, andExecuteAndGetOutputis affected too since it composes over the same primitive.The fix
Each redirected stream is now read to the end by its own dedicated thread (
StreamDrain), and the captured text is returned unmodified.This keeps both properties the callback approach was chosen for:
On the cancelled path the drains are abandoned rather than waited for, but are given a bounded join so they stop touching the streams before the process is disposed.
Test
CommandExecutor_Sync_Captures_The_Same_Bytes_As_Asyncasserts both paths return exactly what the child wrote, for a command whose output ends without a terminator and (on POSIX) embeds a\r\nforeign to the platform, so a path that normalizes terminators cannot pass by coincidence. No existing assertion could observe this — they all compare withTrim().Contains(...).Verified by reverting the fix and re-running:
Full suite with the fix applied: 890 passed, 0 failed. Release build of the provider across all six target frameworks (net10.0 → netstandard2.1): 0 warnings, 0 errors.
CLAUDE.mdis updated, since it described the now-removedBeginOutputReadLinemechanism.🤖 Generated with Claude Code
https://claude.ai/code/session_01R28xwrsRuiwGs84jahEDh3
Generated by Claude Code