Skip to content

Make ICommandExecutor's synchronous path a real primitive - #21

Merged
matt-edmondson merged 5 commits into
mainfrom
claude/github-issues-b0eba5
Sep 7, 2026
Merged

Make ICommandExecutor's synchronous path a real primitive#21
matt-edmondson merged 5 commits into
mainfrom
claude/github-issues-b0eba5

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes #17. Closes #10.

#17Execute blocked on .Result, so sync callers saw AggregateException

All three synchronous Execute overloads were default interface implementations that blocked on Task<T>.Result. That wraps whatever the operation threw in an AggregateException, so a caller writing the obvious try/catch around a synchronous call caught nothing — the exception they were looking for had become an inner exception. That part is observable from outside the library.

The issue framed the fuller fix as a question about which half of the API is the primitive. This takes the same answer the stream providers took: declare a synchronous primitive and let an implementer replace it.

  • ICommandExecutor.Execute(command, environmentVariables, workingDirectory, cancellationToken) is now the synchronous primitive. Execute(command, workingDirectory, cancellationToken) and ExecuteAndGetOutput compose over it, so overriding one member converts all three.
  • The primitive's default body still bridges to ExecuteAsync, but through GetAwaiter().GetResult(), so a failure surfaces unwrapped with its stack trace intact. VSTHRD002 stays suppressed there, but it is now suppressing thread-blocking alone rather than also hiding an exception bug.
  • The synchronous overloads gained an optional CancellationToken (source-compatible), so a caller can bound the wait instead of being pinned to CancellationToken.None.
  • NativeCommandExecutor declares the primitive itself and drives Process synchronously: BeginOutputReadLine/BeginErrorReadLine for capture, and a WaitForExit(timeout) poll that honours the token and kills the child on cancellation, then a parameterless WaitForExit() to flush the read handlers. No thread-pool thread is held for the lifetime of the child process.

On the capture strategy: reading one redirected stream to the end while the other fills its buffer deadlocks, which is the WaitForExit() caveat recorded in ktsu-dev/Sdk#35. The event-based handlers are the documented way around it in a synchronous method. Start-info construction is now shared between the sync and async paths rather than duplicated.

ExecuteAndGetOutput no longer bridges at all — it calls the synchronous primitive directly and throws InvalidOperationException itself.

Both paths also now report a working directory that does not exist as a failed CommandResult. The asynchronous path previously let the resulting Win32Exception escape.

This is a separate defect class from #8 and does not overlap with it.

#10 — the one this. qualifier in the repository

IncrementalHashAdapter's field is renamed to _inner and the qualifier dropped, matching how BitRotateObfuscationProvider resolves the same field/parameter collision. One line, as the issue described.

The issue's aside — that EnforceCodeStyleInBuild is unset, so no .editorconfig rule marked :error is actually enforced at build time — is left alone here. Turning it on is a separate change with its own blast radius.

Unblocking the build

main had been red since at least 3 September: scheduled runs 193–196 all fail identically on f03a686, from the ktsu.Sdk 2.28.0 bump in #20. The build died during compilation, so no test had run in CI for days and this PR inherited a broken base rather than causing one. Two analyzer errors, on every target framework:

  • KTSU0002 wants internals exposed to ktsu.Essentials.Tests, added via InternalsVisibleTo on the two projects that carry internals.
  • KTSU0001 wants a System.Memory reference from every project using Span<T>/Memory<T> — all 47 netstandard2.1 projects. That reference cannot be added: NuGet rejects it during solution restore with NU1510 ("This package is automatically available and does not need to be referenced explicitly. Remove the PackageReference item."), raised against the .slnx where item-level NoWarn does not reach. The two rules cannot both be satisfied, and NuGet is the one describing reality — the framework supplies the package. So KTSU0001 is suppressed instead, scoped to netstandard2.1.

The suppression lives in a new Directory.Build.targets rather than Directory.Build.props: ktsu.Sdk assigns NoWarn outright, and props is imported before the SDK, so an addition there is silently discarded. Confirmed with -getProperty:NoWarn.

That let the test project compile for the first time in days, which surfaced 70 CA1859 errors telling the tests to bind to concrete types. That is wrong here by design — these providers are built on default interface implementations, which are only callable through the interface — so CA1859 joins the test project's existing NoWarn alongside CA1062 and CA1707.

Testing

dotnet test647 passed, 0 failed. The solution builds with 0 warnings and 0 errors on all six target frameworks (net10.0, net9.0, net8.0, net7.0, net6.0, netstandard2.1) with CI's analyzer set loaded.

Eleven new cases in CommandExecutorTests, which previously had a single test for the synchronous path:

  • CommandExecutor_Sync_Execute_Captures_Standard_Error
  • CommandExecutor_Sync_Execute_With_Environment_Variables
  • CommandExecutor_Sync_Execute_Reports_Cancellation
  • CommandExecutor_Sync_ExecuteAndGetOutput_Throws_Unwrapped_On_Failure — the regression test for ICommandExecutor.Execute blocks on .Result, so sync callers see AggregateException #17: asserts InvalidOperationException exactly, with a null InnerException. This failed before the change, because .Result produced an AggregateException.
  • CommandExecutor_Sync_ExecuteAndGetOutput_Returns_Output
  • CommandExecutor_Async_Reports_Cancellation_Before_Start
  • CommandExecutor_Async_Reports_Cancellation_While_Running
  • CommandExecutor_Sync_Execute_Cancels_A_Running_Process — the earlier cancellation test cancelled before the call, so the poll loop and the kill it performs had never executed. Both mid-run tests assert the call returns well inside the child's 20s lifetime rather than waiting it out.
  • CommandExecutor_Reports_Failure_For_Missing_Working_Directory
  • CommandExecutor_Default_Sync_Primitive_Bridges_To_Async and CommandExecutor_Default_Sync_Primitive_Throws_UnwrappedNativeCommandExecutor declares the primitive, so it replaces every one of ICommandExecutor's synchronous defaults and no test ever ran the interface's own bodies. AsyncOnlyCommandExecutor declares only the asynchronous members, which is the shape any other implementer inherits, and these two drive its bridge. The second is the ICommandExecutor.Execute blocks on .Result, so sync callers see AggregateException #17 regression guard for the default body itself.

Two pieces of the new code turned out to be unreachable rather than untested, so they are gone rather than papered over with a test: the synchronous catch (OperationCanceledException) (nothing in that method throws it — the poll uses WaitForExit(int), which takes no token), and TryKill's two empty catch clauses, now one filtered clause.

Coverage on new code: 63.0% → 92.0%, above the 80% quality gate. Duplication 0.0%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg


Generated by Claude Code

Closes #17. Closes #10.

The three synchronous `Execute` overloads blocked on `Task<T>.Result`, which
wraps whatever the operation threw in an `AggregateException`. A caller writing
the obvious try/catch around a synchronous call caught nothing, because the
exception they were looking for had become an inner exception. That is
observable from outside the library.

`ICommandExecutor` now declares a synchronous primitive —
`Execute(command, environmentVariables, workingDirectory, cancellationToken)` —
that the other two synchronous members compose over, mirroring how the stream
providers let an implementer replace a default by declaring the primitive. The
default body still bridges to `ExecuteAsync`, but through
`GetAwaiter().GetResult()`, so a failure surfaces unwrapped with its stack trace
intact. The synchronous overloads also take a `CancellationToken` now, so a
caller can bound the wait instead of being pinned to `CancellationToken.None`.

`NativeCommandExecutor` declares that primitive itself and drives `Process`
synchronously: `BeginOutputReadLine`/`BeginErrorReadLine` for capture (reading
one redirected stream to the end while the other fills its buffer deadlocks),
and a `WaitForExit(timeout)` poll that honours the token and kills the child on
cancellation. No thread-pool thread is held for the process lifetime. The
start-info construction is now shared between the two paths.

Also renames `IncrementalHashAdapter`'s field to `_inner` and drops the `this.`
qualifier, which was the only one in the repository and contradicted both
CLAUDE.md and `.editorconfig`'s `dotnet_style_qualification_for_field`.

Tests: four new cases over the synchronous path — stderr capture, environment
variables, cancellation, and that `ExecuteAndGetOutput` throws an unwrapped
`InvalidOperationException` with no inner exception. Full suite: 640 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg

Copy link
Copy Markdown
Contributor Author

Test on ubuntu-latest and Test on windows-latest are failing, and the failure is not this PR's — main is red with the identical errors.

Both jobs fail during the build, before any test runs:

Essentials/BufferingIncrementalHash.cs(3,1): error KTSU0002: Consider exposing internals to test
project 'ktsu.Essentials.Tests'. Add '[assembly: InternalsVisibleTo("ktsu.Essentials.Tests")]'
to a .cs file.                                    [Essentials.csproj::TargetFramework=net10.0 … net6.0]

Essentials/BufferingIncrementalHash.cs(3,1): error KTSU0001: Project must reference package
'System.Memory'. Add '<PackageReference Include="System.Memory" />' to your .csproj file.
                                                  [Essentials.csproj::TargetFramework=netstandard2.1]

BufferingIncrementalHash.cs is not touched by this PR, and neither is Essentials.csproj.

Evidence it is the base branch, not the PR: the scheduled .NET Workflow run #196 on main (commit f03a686, 2026-09-06 23:10Z — before this branch existed) failed on both ubuntu-latest and windows-latest with the same two diagnostics. The likely trigger is #20, which bumped the ktsu group by 9 packages including ktsu.Sdk to 2.28.0; these are ktsu.Sdk.Analyzers rules, and they are escalated to error.

No re-run spent. A re-run would fail identically — main's own scheduled run is stronger evidence than a second attempt on this branch would be.

Proposed patch, which I have deliberately not pushed here, since it is main's breakage and the remedy is a call about the published package rather than something to fold into an ICommandExecutor change:

<!-- Essentials/Essentials.csproj -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.1'">
  <PackageReference Include="System.Memory" />
</ItemGroup>
// Essentials/AssemblyInfo.cs
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.Essentials.Tests")]

System.Memory is already pinned at 4.6.3 in Directory.Packages.props and referenced by nothing, so no version needs choosing. Two notes on it: the reference is conditioned to netstandard2.1 because that is the only framework the diagnostic fires for, and referencing it on net6.0+ where it is in-box risks NU1510; and adding it does change what ktsu.Essentials declares as a dependency for netstandard2.1 consumers, which is the part worth a deliberate decision rather than a drive-by fix. If the analyzer rule is unwanted rather than correct, adjusting its severity or pinning ktsu.Sdk back is the other way out.

Say the word and I will push either the patch above or a severity change onto this branch; otherwise it wants its own PR against main, and this one goes green behind it.

I have not been able to run these analyzers locally at any point on this branch: the sandbox has .NET SDK 10.0.111 (Roslyn 5.0) and ktsu.Sdk.Analyzers 2.28.0 requires Roslyn 5.9, so loading it fails with CSC : error CS9057. That is also why this surfaced only in CI.


Generated by Claude Code

`main` has been red since at least 3 September — scheduled runs 193 through 196
all fail identically on commit f03a686, so this PR inherited a broken base
rather than causing one. No test has run in CI for days: the build dies during
compilation, before the suite starts.

Two analyzer errors, on every target framework:

  * KTSU0002 wants internals exposed to `ktsu.Essentials.Tests`. Added via the
    `InternalsVisibleTo` item on `Essentials` and `Essentials.All`, the two
    projects that carry internals.
  * KTSU0001 wants a `System.Memory` reference from every project using
    `Span<T>`/`Memory<T>` — 41 of them. Declared once in `Directory.Build.props`
    for netstandard2.1 rather than copied into 41 csproj files.

KTSU0001 collides head-on with NuGet's NU1510, which calls that same reference
redundant because netstandard2.1 already carries those types in its reference
assemblies. One of the two has to be silenced; `NoWarn="NU1510"` on that single
item follows ktsu.Sdk's own stated requirement and is as narrow as the
suppression gets.

Fixing those let the test project compile for the first time in days, which
surfaced 70 `CA1859` errors telling the tests to bind to concrete types. That is
wrong here by design: these providers are built on default interface
implementations, which are only callable through the interface, so following it
would either fail to compile or dispatch elsewhere — the suite would stop
exercising the contract it exists to cover. `CA1859` joins `CA1062` and `CA1707`
in the test project's existing `NoWarn`, which is this repository's established
way of turning off rules that do not apply to test code.

Verified rather than inferred. The sandbox SDK (10.0.111, Roslyn 5.0) could not
load `ktsu.Sdk.Analyzers` (needs 5.9) and failed with CS9057, which is why the
earlier push could not see any of this. Overriding the compiler with
`Microsoft.Net.Compilers.Toolset` 5.9.0 from NuGet reproduces CI's analyzer set
exactly: the original two errors reproduced locally on the same files and target
frameworks, and the whole solution now builds with 0 warnings and 0 errors.
CLAUDE.md documents the technique and both suppressions.

Essentials.Tests: 640 passed, 0 failed, in Release with the analyzers loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
The previous commit satisfied KTSU0001 by referencing System.Memory for
netstandard2.1, with NoWarn="NU1510" on the item. That passed locally and CI
still rejected it:

  error NU1510: PackageReference System.Memory will not be pruned. This package
  is automatically available and does not need to be referenced explicitly.
  Remove the PackageReference item. [Essentials.slnx]

Two differences from the local run. CI's SDK (10.0.400) words NU1510 as a flat
refusal where the sandbox's 10.0.111 calls the package "likely unnecessary", and
CI raises it during solution restore, attributed to the .slnx, where NoWarn
metadata on the item does not reach it.

So the reference cannot be added, and KTSU0001 cannot be satisfied. Of the two,
NuGet is the one describing reality: the framework supplies these types on
netstandard2.1, which is why it prunes the package. KTSU0001's premise does not
hold on this SDK, so it is suppressed instead — scoped to netstandard2.1, the
only target it fires on.

The suppression lives in a new Directory.Build.targets, not Directory.Build.props:
ktsu.Sdk assigns NoWarn outright, and props is imported before the SDK, so the
addition was silently discarded. Confirmed with -getProperty:NoWarn, which showed
the value without it. That is also why the first attempt at this looked correct
and did nothing.

Verified with CI's analyzer set reproduced locally via Microsoft.Net.Compilers.Toolset
5.9.0: whole solution builds with 0 warnings and 0 errors, no System.Memory
reference anywhere outside Directory.Packages.props' PackageVersion, and
Essentials.Tests passes 640/640 in Release. CLAUDE.md records the conflict, the
resolution and the props-vs-targets trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
…patch]

CI is green on 386ef7f — tests pass on Linux and Windows, analysis and the
build are clean — but the SonarCloud quality gate fails on coverage of new
code: 63.0% against a required 80%.

Measured locally with the Microsoft coverage collector rather than guessed at.
Sonar's coverage metric folds branch coverage into line coverage, and computing
it the same way over the diff reproduces the gate's reading closely: 69.0%
locally against the 63.0% reported. The gap between them is stable, so the
same calculation is a usable check on a fix.

What was untested, and now is:

  * `ICommandExecutor`'s own synchronous defaults. `NativeCommandExecutor`
    declares the primitive, so it replaces every one of them and no test ever
    ran the interface's bodies. `AsyncOnlyCommandExecutor` declares only the
    asynchronous members, which is the shape any other implementer inherits,
    and two tests drive its bridge: one for the result, one asserting the
    failure arrives unwrapped. The second is the #17 regression guard for the
    default body itself — the existing one only covered the native path.
  * `ExecuteAndGetOutput`'s success path. Only its throwing path was covered.
  * Cancellation while a process is running, on both paths. The existing test
    cancelled before the call, so the synchronous poll loop and the kill it
    performs had never executed. Both new tests assert the call returns well
    inside the child's 20s lifetime rather than waiting it out.
  * A working directory that does not exist. This is a behaviour this branch
    introduced and had not tested: the asynchronous path previously let the
    resulting `Win32Exception` escape, and now reports it as a failed result
    like the synchronous one does.

Two pieces of the new code were unreachable rather than untested, so they are
gone rather than papered over with a test:

  * The synchronous `catch (OperationCanceledException)`. Nothing in that
    method throws it — the poll uses `WaitForExit(int)`, which takes no token,
    and cancellation returns directly from the loop.
  * `TryKill`'s two empty catch clauses, which are now one filtered clause.
    Both described the same race and neither did anything.

Coverage of the diff, computed as Sonar computes it: 69.0% -> 90.2%.
647 tests pass, and the solution builds with 0 warnings and 0 errors on all
six target frameworks with CI's analyzer set loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
Comment thread Essentials.Tests/CommandExecutorTests.cs Fixed
CodeQL flagged the `Path.Combine` added in the previous commit: a later rooted
segment silently discards the earlier ones. The second segment here is a GUID,
so it can never be rooted and the two calls produce the same string — but
`Path.Join` concatenates unconditionally, which is what this line means, and it
costs nothing to say so.

647 tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1cVfacJWw1uM42DUmPfUg
@sonarqubecloud

sonarqubecloud Bot commented Sep 7, 2026

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 9915963 into main Sep 7, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/github-issues-b0eba5 branch September 7, 2026 08:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants