Skip to content

Delegate git process invocation to ktsu.RunCommand #27

Description

@matt-edmondson

What's hand-rolled

GitRunner.RunAsync starts git as a child process by hand: builds a Process/ProcessStartInfo, redirects stdout/stderr, races WaitForExitAsync against a timeout, kills the whole process tree on timeout or cancellation, and drains the output streams with a bounded grace period afterward:

Ensure.NotNull(invocation);
using Process process = new() { StartInfo = BuildStartInfo(invocation) };
process.Start();
// git is never fed anything, and a child holding an open stdin it is waiting on is a hang
// rather than an error.
process.StandardInput.Close();
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
Task<string> standardError = process.StandardError.ReadToEndAsync(CancellationToken.None);
using CancellationTokenSource timeout = new(invocation.Timeout);
using CancellationTokenSource linked =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
try
{
await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
Kill(process);
await DrainAsync(standardOutput, standardError).ConfigureAwait(false);
// A timeout is this service's own decision and has an answer to report. A cancellation is
// the caller giving up, and there is nobody left to report anything to.
cancellationToken.ThrowIfCancellationRequested();
return new GitResult(-1, string.Empty, "The git command exceeded its timeout.", TimedOut: true);
}
try
{
return new GitResult(
process.ExitCode,
await standardOutput.ConfigureAwait(false),
await standardError.ConfigureAwait(false),
TimedOut: false);
}
catch (DecoderFallbackException)
{
return new GitResult(
-1,
string.Empty,
"git produced output that is not valid UTF-8, so it cannot be read without guessing.",
TimedOut: false);
}
}
/// <summary>
/// Kills the process and everything it started.
/// </summary>
/// <remarks>
/// The tree, not just the process: git delegates transport to a helper child, and killing only the

The kill/drain/timeout machinery (Kill, DrainAsync, the linked-cancellation-token dance) is generic process-lifecycle plumbing, not anything specific to git:

/// operational failure of a service shaped like this.
/// </remarks>
private static void Kill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// The process exited between the check and the kill. Nothing left to do.
}
catch (NotSupportedException)
{
// Killing a tree is unsupported on this platform, and the process is already gone or will
// be reaped when its handle is disposed.
}
}
private static async Task DrainAsync(Task<string> standardOutput, Task<string> standardError)
{
try
{
await Task.WhenAll(standardOutput, standardError).WaitAsync(DrainTimeout).ConfigureAwait(false);
}
catch (Exception failure) when (failure is TimeoutException or DecoderFallbackException)
{
// The output of a killed command is not reported, so failing to read it changes nothing.
}
}
private ProcessStartInfo BuildStartInfo(GitInvocation invocation)
{
GitBranchStateCacheOptions settings = options.Value;

What ktsu.RunCommand provides

ktsu.RunCommand.RunCommand.ExecuteAsync(string fileName, IEnumerable<string> arguments, OutputHandler outputHandler, CommandOptions options, CancellationToken cancellationToken):

https://github.com/ktsu-dev/RunCommand/blob/cbf667de10ee14073e1198ce5125ad176a7903df/RunCommand/RunCommand.cs#L262-L271

It already does exactly the same core sequence GitRunner hand-rolls: starts the process with an argument list (no shell, no quoting), redirects and reads stdout/stderr concurrently (AsyncProcessStreamReader), and on cancellation kills the entire process tree (TryKill, entireProcessTree: true on non-netstandard2.x targets) before rethrowing OperationCanceledException:

https://github.com/ktsu-dev/RunCommand/blob/cbf667de10ee14073e1198ce5125ad176a7903df/RunCommand/RunCommand.cs#L346-L389

CommandOptions also covers the two other knobs GitRunner needs:

OutputHandler.Encoding accepts a custom Encoding, so the strict, throw-on-invalid-bytes UTF8Encoding GitRunner builds today can be passed straight through:

https://github.com/ktsu-dev/RunCommand/blob/cbf667de10ee14073e1198ce5125ad176a7903df/RunCommand/OutputHandler.cs#L11-L14

Why it's worth it

RunCommand's cancellation path was specifically hardened against the exact race GitRunner guards against by hand: its own CLAUDE.md documents that killing the process can let the "normal exit" path win the race against a cancelled wait, which would return the killed process's exit code and throw nothing — the fix is a ThrowIfCancellationRequested() re-check after the await, covered by a regression test that repeats a 1 ms cancellation 50 times. GitRunner reimplements the same shape (kill, then decide what to report) without that test coverage backing it. Delegating removes ~90 lines of process start/kill/drain plumbing (RunAsync, Kill, DrainAsync, BuildStartInfo's non-environment parts) and leaves GitRunner holding only what's actually git-specific: the GIT_* / GIT_CONFIG_* environment protocol and the git-vs-caller timeout/cancellation distinction, built as a CommandOptions.EnvironmentVariables overlay and a linked CancellationTokenSource around the ExecuteAsync call.

Compatibility

  • Subject (ktsu.GitBranchStateCache) targets: net10.0 only (its .csproj pins <TargetFramework>net10.0</TargetFramework> deliberately, as an ASP.NET Core component).
  • ktsu.RunCommand targets: net10.0;net9.0;net8.0;net7.0;net6.0;net5.0;netstandard2.0;netstandard2.1 (per its own CLAUDE.md) — covers net10.0, and the process-tree kill (entireProcessTree: true) is available on exactly the non-netstandard2.x targets, i.e. it applies on net10.0.
  • Dependency direction: ktsu.RunCommand's Directory.Packages.props references only ktsu.Semantics.Paths, ktsu.Semantics.Strings, Polyfill, System.Memory, System.Threading.Tasks.Extensions — no dependency on ktsu.GitBranchStateCache, so no cycle.

Sketch

Before (GitRunner.RunAsync, abbreviated):

using Process process = new() { StartInfo = BuildStartInfo(invocation) };
process.Start();
process.StandardInput.Close();
Task<string> standardOutput = process.StandardOutput.ReadToEndAsync(CancellationToken.None);
Task<string> standardError = process.StandardError.ReadToEndAsync(CancellationToken.None);
using CancellationTokenSource timeout = new(invocation.Timeout);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
try
{
    await process.WaitForExitAsync(linked.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
    Kill(process);
    await DrainAsync(standardOutput, standardError).ConfigureAwait(false);
    cancellationToken.ThrowIfCancellationRequested();
    return new GitResult(-1, string.Empty, "The git command exceeded its timeout.", TimedOut: true);
}
// ... build GitResult from standardOutput/standardError

After (sketch — ApplyEnvironment's GIT_* protocol stays as a helper building the overlay dictionary):

using CancellationTokenSource timeout = new(invocation.Timeout);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);

StringBuilder stdout = new();
StringBuilder stderr = new();
OutputHandler handler = new(chunk => stdout.Append(chunk), chunk => stderr.Append(chunk), StrictUtf8);
CommandOptions options = new()
{
    WorkingDirectory = invocation.WorkingDirectory,
    EnvironmentVariables = BuildEnvironmentOverlay(invocation, settings), // the GIT_* / GIT_CONFIG_* entries
};

try
{
    int exitCode = await RunCommand.ExecuteAsync(settings.GitExecutable, invocation.Arguments, handler, options, linked.Token).ConfigureAwait(false);
    return new GitResult(exitCode, stdout.ToString(), stderr.ToString(), TimedOut: false);
}
catch (OperationCanceledException)
{
    cancellationToken.ThrowIfCancellationRequested();
    return new GitResult(-1, string.Empty, "The git command exceeded its timeout.", TimedOut: true);
}

Caveats

  • RunCommand's OutputHandler delivers raw, undelimited chunks rather than the single joined string GitRunner builds with ReadToEndAsync. Reassembling the full text needs a StringBuilder per stream in the caller, as sketched above — a small but real difference from today's shape.
  • A decoding failure (DecoderFallbackException) from the strict UTF-8 encoding would need to be caught around the ExecuteAsync call instead of around two Task<string> awaits; GitRunner's current message for that case ("git produced output that is not valid UTF-8...") would need to move to that catch block.
  • RunCommand does not expose a bounded post-kill drain timeout (DrainTimeout in GitRunner today) as a separate knob — its AsyncProcessStreamReader reads until the process's pipes close after being killed, which is normally immediate but isn't independently time-boxed the way GitRunner's explicit 5-second DrainAsync is. Worth confirming this doesn't reintroduce the grandchild-holds-the-pipe-open case the current DrainTimeout comment calls out.
  • This is an internal, non-public-API change (GitRunner/IGitRunner are public types but this repo is a service, not a library consumed by other ktsu packages), so no downstream break, but it is worth re-running the existing GitRunner/GitBranchStateCache.Tests suite (particularly around timeout and cancellation) after the swap given the subtlety noted above.

Activity

  1. matt-edmondson commented on Sep 15, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Category: Improvement
    Priority: Low
    Area: GitBranchStateCache — git process invocation

    Low: nothing broken, consolidation onto ktsu.RunCommand.

    Suggested assignment: none specific.

    Related: ktsu-dev/KtsuBuild#136 delegates process execution to the same library in the same audit rotation — separate repos, not a duplicate. Tracked under ktsu-dev/.github#15.

    Worth knowing before starting: ktsu-dev/SvnToGit#71 is an open build failure from CS0618 on the obsolete RunCommand.ExecuteAsync overload — evidence that this library's API is mid-deprecation. Target the current overload so this repo doesn't acquire the same break.

    Also consider whether ktsu.GitIntegration is the better fit than ktsu.RunCommand for some of these call sites. This repo shells out to git specifically, not to arbitrary processes, and the org already has a git-operations library (see ktsu-dev/KtsuTools#163, adopting it for exactly that reason). RunCommand is the right answer if the invocations are ad-hoc plumbing git porcelain that GitIntegration doesn't model; it is the wrong one if GitIntegration already exposes the operations being shelled out for. Worth five minutes checking which before committing, since redoing it later is more work than deciding now.

    Open PR covering this: none.

    Suggested next step: enumerate the git invocations in this repo and check them against ktsu.GitIntegration's surface first, then fall back to ktsu.RunCommand for whatever remains.


    Generated by Claude Code

  2. matt-edmondson commented on Sep 21, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Not actionable as a clean swap yet. Two of the guarantees GitRunner documents as load-bearing cannot currently be preserved through ktsu.RunCommand's surface. Verified empirically against ktsu.RunCommand 1.5.23 on net10.0, not read off the source.

    What does work, so it is not in doubt: CommandOptions.EnvironmentVariables is IReadOnlyDictionary<string, string?> and behaves exactly as the issue describes — the child inherits the parent environment, listed keys are overlaid, and a null value removes an inherited key. So the GIT_* stripping and the GIT_CONFIG_* protocol, which are this class's whole security posture, are expressible. Valid non-ASCII UTF-8 also round-trips correctly through OutputHandler.Encoding.

    1. stdin is neither redirected nor closed

    GitRunner sets RedirectStandardInput = true and calls process.StandardInput.Close(), with the comment that a child holding an open stdin it is waiting on "is a hang rather than an error". RunCommand does not redirect stdin, so the child inherits the service's.

    A child that reads stdin blocks until the token is cancelled:

    /bin/sh -c "read x; echo got:[$x]"   → blocked until cancellation (TaskCanceledException)
    

    CommandOptions exposes WorkingDirectory, EnvironmentVariables and Elevation — no stdin knob. GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never cover the prompting case but not a generic read, so adopting this as-is reintroduces the hang the current code deliberately guards against, in a long-running service.

    2. Strict UTF-8 reports a decode failure only sometimes

    This is the more serious one, because the failure mode is silence. With new UTF8Encoding(false, throwOnInvalidBytes: true) passed as OutputHandler.Encoding, four runs each:

    git output result
    invalid bytes surrounded by valid text (before\n\xFF\xFE\nafter\n) AggregateException wrapping DecoderFallbackException — 4/4
    invalid bytes only (\xFF\xFE) exit=0, empty output, no exception — 4/4

    Today ReadToEndAsync with StandardOutputEncoding = StrictUtf8 throws DecoderFallbackException in both cases, and GitRunner turns that into an explicit GitResult: "git produced output that is not valid UTF-8, so it cannot be read without guessing."

    After the swap, the second row becomes an empty successful result. That is precisely the outcome the StrictUtf8 comment says the strict encoding exists to prevent — output this service cannot represent being reported as something that "looks fine and matches nothing". An undecodable branch or path name would read as no branches rather than as an error.

    Note also that even the detected case arrives wrapped in AggregateException, so the existing catch (DecoderFallbackException) would not catch it unchanged.

    On ktsu.GitIntegration

    Checked as the previous triage note asked. It is not the alternative here: the point of GitRunner is how git is invoked — the credential never touching a command line, system and global config switched off, inherited GIT_* dropped — not which porcelain is run. A library that models git operations would have to expose that same environment protocol to be usable, so it does not remove the need for this layer.

    What would unblock it

    Either an upstream ktsu.RunCommand change — a stdin option on CommandOptions, and a decode failure raised consistently regardless of whether any valid text accompanied the bad bytes — or an explicit decision here to accept both differences. The second is a judgement call about a credential-handling service's failure modes, so it wants a maintainer rather than a drive-by refactor.

    The DrainTimeout caveat the issue already raises is still open too, and is the least of the three.

    Leaving this unassigned. The reuse itself is still worth doing once the stdin and decode gaps are settled; nothing above argues against the direction, only against doing it blind today.


    Generated by Claude Code

  3. matt-edmondson commented on Sep 25, 2026

    @matt-edmondson
    ContributorAuthor

    Both upstream blockers are now addressed

    The 2026-09-21 triage named two things that would have to change in ktsu.RunCommand before this swap could preserve GitRunner's guarantees. Re-checked both against current main (d06609e, v1.6.2):

    The decode gap is already fixed. It was measured against 1.5.23; v1.6.0's "Honour OutputHandler.Encoding regardless of a byte order mark" addressed it. AsyncProcessStreamReader now builds its readers with detectEncodingFromByteOrderMarks: false, and its own comment names exactly the case recorded here — output starting FF FE being decoded as UTF-16LE whatever OutputHandler.Encoding said, so a strict encoding reported no error on bytes it should have rejected. Nothing left to do for this one.

    The stdin gap was still live, and now has a PR. Confirmed it reproduces: with the caller's standard input a pipe held open with no data, a command that reads standard input waits there and the call ends only on cancellation — TaskCanceledException after 8.0s against an 8-second token. With standard input at /dev/null it returns in 0.1s, which is why this does not show up on a developer's machine.

    Filed as ktsu-dev/RunCommand#81 and fixed in ktsu-dev/RunCommand#82: CommandOptions.StandardInput takes a StandardInputMode of Inherit (the default, unchanged behaviour) or Closed, which redirects standard input and closes it so a read reports end of stream. Same thing GitRunner does by hand today. Measured after the change: 8.0s hang → 0.1s completion.

    Worth noting one detail from that work, because it bears on how this repo would test its own adoption: redirecting without closing is not a fix. It leaves the command holding a pipe nobody writes to, which is the same wait as inheriting. GitRunner's existing process.StandardInput.Close() is doing real work, not tidying up.

    What remains before this issue is actionable. The DrainTimeout caveat the issue raises is untouched and is still the least of the three. Once #82 lands and ships, the swap needs CommandOptions { StandardInput = StandardInputMode.Closed } at each call site to keep the current no-hang guarantee — it is not the default, deliberately, since changing that would break callers relying on inheritance. Whether it should be the default is raised in #82 and left as a maintainer's call.

    Not assigning myself; nothing branched here.


    Generated by Claude Code

  4. matt-edmondson commented on Sep 26, 2026

    @matt-edmondson
    ContributorAuthor

    Triage (re-run after 2026-09-25 update)

    • Category: Improvement (cross-library reuse)
    • Priority: Low
    • Assignment: Repo maintainer. This depends on the RunCommand release cadence.
    • Status: One upstream blocker is fixed and the other has a fix up:
    • Remaining caveat: The DrainTimeout behaviour is still open.
    • Duplicates / related: Part of the reuse audit in Cross-library reuse audit log .github#15.
    • In progress: No open PR in this repo.

    Next step: Once a RunCommand release ships #82, bump the dependency and delegate git process invocation to RunCommand. Decide whether DrainTimeout blocks that or can be a follow-up.


    Generated by Claude Code

  5. matt-edmondson commented on Sep 28, 2026

    @matt-edmondson
    ContributorAuthor

    Decision (maintainer, 2026-09-28)

    Next reader: blocked on a RunCommand release that includes #82. Then bump the dependency and delegate GitRunner.RunAsync to RunCommand.ExecuteAsync, setting StandardInput = StandardInputMode.Closed at each call site. Keep the GIT_* environment overlay and the strict UTF-8 handling, and re-run the timeout and cancellation tests.


    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions