Skip to content

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

Closed

Description

@matt-edmondson

What happens

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-202
private static void AppendLine(StringBuilder buffer, string? line)
{
    if (line is 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.

Activity

  1. matt-edmondson commented on Sep 11, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Priority: Medium Effort: Medium

    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.
    • If raw capture turns out infeasible without reintroducing the thread-blocking problem ICommandExecutor.Execute blocks on .Result, so sync callers see AggregateException #17 fixed, document the normalization explicitly on Execute's XML doc instead.
    • 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).


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions