Skip to content

fix: capture raw output in the synchronous command path [patch] - #27

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-1qcsdz
Sep 14, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/nice-davinci-1qcsdz

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #23

The problem

NativeCommandExecutor has two capture paths that disagreed about the bytes of the child's output.

ExecuteAsync reads the redirected streams raw, preserving exactly what the child wrote. The synchronous primitive captured line by line through OutputDataReceived/ErrorDataReceived and reassembled with StringBuilder.AppendLine, which appends Environment.NewLine after every line — including the last — after the Process line splitter has already discarded the child's own terminators.

On Linux, printf 'a\r\nb':

call StandardOutput
ExecuteAsync(cmd).Result "a\r\nb"
Execute(cmd) "a\nb\n"

ICommandExecutor documents 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, and ExecuteAndGetOutput is 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:

  • No deadlock — neither stream can fill its buffer while the other is being read, which is what reading one to the end on the calling thread would cause.
  • No pool thread held — a dedicated thread rather than a pooled one, which is the cost the synchronous primitive exists to avoid (ICommandExecutor.Execute blocks on .Result, so sync callers see AggregateException #17). The calling thread also stays free to keep polling the cancellation token, so a silent child no longer blocks cancellation.

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_Async asserts both paths return exactly what the child wrote, for a command whose output ends without a terminator and (on POSIX) embeds a \r\n foreign to the platform, so a path that normalizes terminators cannot pass by coincidence. No existing assertion could observe this — they all compare with Trim().Contains(...).

Verified by reverting the fix and re-running:

failed CommandExecutor_Sync_Captures_The_Same_Bytes_As_Async
  expected: "a\r\nb"
  actual:   "a\nb\n"

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.md is updated, since it described the now-removed BeginOutputReadLine mechanism.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R28xwrsRuiwGs84jahEDh3


Generated by Claude Code

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
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit e544a1d into main Sep 14, 2026
13 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nice-davinci-1qcsdz branch September 14, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NativeCommandExecutor's synchronous path normalizes line endings and appends a trailing newline; the async path does not

2 participants