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

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions