Skip to content

[minor] Delegate process execution to ktsu.RunCommand - #147

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/nifty-bohr-aoyvx4
Sep 22, 2026
Merged

matt-edmondson merged 3 commits into
mainfrom
claude/nifty-bohr-aoyvx4

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #136

ProcessRunner built its own Process/ProcessStartInfo plumbing. The two gaps the issue names are correctness bugs rather than line count, and both are closed here.

Cancellation no longer abandons the child

The old implementation only stopped awaiting WaitForExitAsync. Nothing killed the child, so a cancelled run left it — and anything it had started — running unsupervised. For a build tool the children are compilers and test hosts, so an abandoned tree holds file locks and keeps consuming a runner. ktsu.RunCommand kills the whole process tree.

RunWithCallbackAsync_WhenCancelled_KillsTheChildInsteadOfAbandoningIt covers this: the child writes a marker file shortly after the cancellation point and only then settles into a long sleep, so a merely-abandoned child reaches the marker well before the assertion.

Arguments no longer have to be quoted by hand

IProcessRunner gains IReadOnlyList<string> overloads, where each argument is its own value — nothing has to be quoted, so nothing can be quoted wrongly.

GitHubService had three hand-quoted call sites (release creation, asset upload, topic setting), all using a quoter that wrapped any value containing a space and escaped nothing, so an asset path containing a quote produced a malformed command. All three now pass a list.

The string overloads stay, because ~40 call sites still build their arguments that way. CommandLineArguments splits those back apart using the same CommandLineToArgvW rules .NET applies to ProcessStartInfo.Arguments, and ArgumentList's re-quoting is the exact inverse — so a string that reached a child process one way still reaches it that way.

Two things worth a reviewer's attention

The obsolete API. Every string command overload of RunCommand.Execute/ExecuteAsync is [Obsolete] — verified by reflecting over the shipped assembly, and the reason ktsu-dev/SvnToGit#71 is a CS0618 build failure. This targets the current ExecuteAsync(fileName, arguments, handler, options, cancellationToken) overload.

LineOutputHandler drops unterminated output. The issue's sketch used RunCommand's own line-splitting handler. It discards a final line that is not newline-terminated: printf 'content' reaches the caller as nothing at all. That would have silently dropped output from any command not ending in a newline. LineAssembler does the splitting over the raw OutputHandler instead, flushing the remainder — including on the cancelled and failed paths, where the partial output is exactly what someone diagnosing the failure wants. It follows ReadLine semantics (\n, \r, \r\n), so carriage-return progress output still arrives as written rather than accumulating into one enormous line.

New dependencies

Adds direct references to ktsu.Semantics.Paths and ktsu.Semantics.Strings. KTSU0006 requires these to be direct because CommandOptions.WorkingDirectory is an AbsoluteDirectoryPath that ProcessRunner names itself. This is the caveat the issue flagged for maintainer awareness.

Verification

  • dotnet build KtsuBuild.slnx — clean, 0 warnings.
  • dotnet test KtsuBuild.slnx — 741/741 pass (703 pre-existing, 38 new).
  • Each new test was proven to fail without its fix by temporarily reverting:
    • restoring the old Process plumbing → the cancellation test fails with "The child process outlived cancellation and wrote its marker, so it was abandoned rather than killed."
    • swapping LineAssembler for LineOutputHandler → the three unterminated-final-line tests fail.
  • RunCommand's line handling, verbatim argument passing and process-tree kill were each confirmed empirically against the real package before writing the adapter, as the triage note asked.

The public surface of IProcessRunner is additive; ProcessRunner is its only implementer in the repository, and the existing tests substitute the interface.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TGwRLtZoAdsa46QDLPYBvR


Generated by Claude Code

matt-edmondson and others added 2 commits September 21, 2026 12:41
ProcessRunner built its own Process/ProcessStartInfo plumbing. Two gaps in
that implementation were correctness bugs rather than line count:

- Cancellation only stopped awaiting WaitForExitAsync. Nothing killed the
  child, so a cancelled run left it — and anything it had started — running
  unsupervised. For a build tool that means an orphaned compiler or test host
  still holding file locks. ktsu.RunCommand kills the whole process tree.

- Arguments were taken as one pre-joined string, so callers building a command
  by hand had to quote it themselves. GitHubService did exactly that, in three
  places, with a quoter that wrapped any value containing a space and escaped
  nothing — an asset path containing a quote produced a malformed command.

ProcessRunner is now an adapter over RunCommand's current, non-obsolete
ExecuteAsync overload; every string-taking overload of that API is [Obsolete]
and is deliberately avoided.

IProcessRunner gains IReadOnlyList<string> overloads, where nothing has to be
quoted so nothing can be quoted wrongly, and GitHubService's three hand-quoted
call sites now use them. The existing string overloads stay, since ~40 call
sites still build their arguments that way; CommandLineArguments splits those
back apart using the same CommandLineToArgvW rules .NET applies to
ProcessStartInfo.Arguments, which ArgumentList's re-quoting is the inverse of,
so what reached a child process before still reaches it.

RunCommand's own LineOutputHandler discards a final line that is not
newline-terminated, which would have silently dropped output. LineAssembler
does the line splitting instead and flushes the remainder, including on the
cancelled and failed paths.

Adds direct references to ktsu.Semantics.Paths and ktsu.Semantics.Strings,
which KTSU0006 requires because CommandOptions.WorkingDirectory is an
AbsoluteDirectoryPath that ProcessRunner names directly.

Fixes #136

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGwRLtZoAdsa46QDLPYBvR
Both were faults in the tests rather than in ProcessRunner; the Linux leg was
green and the two other legs each failed two tests.

macOS compared working directories as strings. The temp directory is reached
through /var, a symlink to /private/var, so the child reported the resolved
form while the test held the unresolved one. The comment in the test even
named this case without handling it. Both working-directory tests now drop a
sentinel file in the expected directory and look for it through the path the
child reported, which settles which directory it is without either side having
to resolve anything.

Windows used the cmd idiom "<nul set /p=content" to write without a trailing
newline, which exited 1 — the form needs quoting as set /p "=content", and
that quoting is delicate enough to be its own source of failure. PowerShell
writes the bytes directly instead, so there is nothing to quote.

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

Copy link
Copy Markdown
Contributor Author

Status on the CI here, since two different things went red and only one of them was mine.

Test on windows-latest / Test on macos-latest — mine, fixed in 1334d2f.

Four failures across the two legs, all of them faults in the tests I added rather than in ProcessRunner; the Linux leg was green throughout.

  • macOS compared working directories as strings. The temp directory is reached through /var, a symlink to /private/var, so the child reported the resolved form while the test held the unresolved one. Both working-directory tests now drop a sentinel file in the expected directory and look for it through the path the child reported, which settles which directory it is without either side having to resolve anything.
  • Windows used the cmd idiom <nul set /p=content to write without a trailing newline, which exits 1 — the form needs quoting as set /p "=content". PowerShell writes the bytes directly instead, so there is nothing to quote.

github-advanced-security ("Code scanning AI findings") — not this PR's, and not fixable from here.

It fails about 35 seconds in, on both heads, before reaching any analysis:

errorType: 'quota',
statusCode: 402,
[cause]: [Error: You have exceeded your monthly quota]

That is the Copilot-backed agent hitting an account-level monthly quota, so it will fail identically on every PR in this repository until the quota resets or is raised — nothing in this diff reaches it, and it has posted no findings on either head. I have not spent a re-run on it, because a 402 quota response is not a flake and re-running would return the same thing. Raising or waiting out the Copilot quota is the only fix, and that is an account decision rather than a code change.

I am watching this PR and will keep checking until the remaining legs report on 1334d2f.


Generated by Claude Code

The quality gate passed, but two findings were real and both were mine.

S2699 (blocker): Append_NullCallback_DoesNotThrow asserted nothing — it
relied on an exception failing the test, which is invisible to a reader and
to Sonar. It now exercises the null-callback path and then asserts the same
input through a real callback, so the lines it discards are shown to have
been there to discard. Dropping the null check in LineAssembler still fails
it.

S3776 (critical): CommandLineArguments.NextArgument had a cognitive
complexity of 30 against a limit of 15, from two nested scanning loops
sharing the read position. The backslash run and the quote are now each
consumed by their own method, leaving the main loop as the character walk it
reads as. No behaviour change; the splitter tests pin every rule.

The remaining 20 findings are MSTEST0049 and MSTEST0068 informational
suggestions on test code, left alone deliberately: CollectionAssert is the
convention already used throughout this suite.

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

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit fa9198a into main Sep 22, 2026
11 of 12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/nifty-bohr-aoyvx4 branch September 22, 2026 00:32
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.

Delegate process execution to ktsu.RunCommand

1 participant