Dependency injection

Previous: Overview

The ProcessKit.Extensions.DependencyInjection package wires ProcessKit into Microsoft.Extensions.DependencyInjection. It stays dependency-light — only the DI, Logging, Options, and Configuration extension packages that a DI-integration package inevitably needs, and no hosting dependency — and every registration uses TryAdd, so a pre-existing registration of yours always wins.

The runner

AddProcessKit() registers IProcessRunner as a singleton JobRunner. When the container also has an ILoggerFactory, the runner is wrapped so every run it drives emits ProcessKit's lifecycle events under the ProcessKit category (argv/env never logged — see Observability).

services.AddProcessKit();

// Injected anywhere:
public class Deployer(IProcessRunner runner)
{
    public Task<FSharpResult<string, ProcessError>> Deploy() =>
        runner.RunAsync(new Command("deploy"), CancellationToken.None);
}

Default settings (ProcessKitOptions)

Configure defaults applied to every DI-resolved run — from code or from configuration. Each default is applied only when the command does not set it itself, so a per-command value always wins.

// From code:
services.AddProcessKit(o =>
{
    o.DefaultTimeout = TimeSpan.FromSeconds(30);
    o.DefaultWorkingDirectory = "/app";
});

// …or bound from an IConfiguration section (appsettings.json "ProcessKit"):
services.AddProcessKit(configuration.GetSection("ProcessKit"));

ProcessKitOptions covers what a primitive runner can apply on the spawn path — timeout and working directory. Retry is a verb-layer policy (the retry loop reads the command before this runner sees it), so a retry default can't ride on the bare runner; set it — and richer per-tool defaults like encoding, ok-codes, and environment — on a named client instead, whose template precedes the verb:

services.AddProcessKitClient("git", "git",
    c => c.WithDefaults(cmd => cmd.Retry(3, TimeSpan.FromSeconds(1), e => e.IsTransient)));

Named / keyed tool clients

Register a keyed CliClient per external tool, so an app injects "the git client" or "the ffmpeg client" by role. Each client runs through the container's registered IProcessRunner (so it is logger-aware and honours a shared group or a test runner), and configure applies shared defaults via the CliClient builder. The callback runs when the keyed client is resolved and must return a non-null CliClient for the registered program. A null result is rejected with ArgumentNullException, and returning a client for another program is rejected with ArgumentException; both exceptions name configure and surface from keyed-client resolution instead of being reported as a missing registration.

Across the DI registration overloads, a null argument is rejected with ArgumentNullException whose ParamName matches the public signature: services, configure, configuration, name, or program. The configured-client result check above remains deferred until keyed-client resolution and names the callback parameter, configure.

services.AddProcessKit();
services.AddProcessKitClient("git", "git", c => c.WithDefaults(cmd => cmd.CurrentDir("/repo")));
services.AddProcessKitClient("ffmpeg", "ffmpeg");

public class Repo([FromKeyedServices("git")] CliClient git)
{
    public Task<FSharpResult<string, ProcessError>> Status() => git.RunAsync(["status"]);
}

A shared, container-managed process group

AddProcessKitGroup() backs IProcessRunner with a single shared ProcessGroup whose lifetime is the container's — every run goes into one kill-on-dispose container, and disposing the provider reaps the whole tree. Ideal for a hosted service that should leave no orphaned children when it stops. The ProcessGroup is also registered directly, so you can inject it for tree control (Signal / Suspend / Members / …). Call it instead of AddProcessKit() when you want a shared group.

services.AddProcessKitGroup();
// IProcessRunner now runs every command into the shared group;
// await using the provider (or host shutdown) reaps all children.

Both AddProcessKitGroup() and AddProcessKit() register IProcessRunner with TryAdd, so call one or the other — whichever runs first wins. If AddProcessKit() runs first, IProcessRunner stays the per-run JobRunner, and a later AddProcessKitGroup() still registers the ProcessGroup but no runs go into it — an easy-to-miss wiring error. AddProcessKitGroup(configure) / AddProcessKitGroup(configuration) apply the same ProcessKitOptions defaults as the AddProcessKit overloads.

Hosting a supervised child

Use the ProcessKit.Extensions.Hosting package when a supervised child should live for the host's lifetime. It depends only on Microsoft.Extensions.Hosting.Abstractions, discovers an existing DI-registered IProcessRunner when one is present, starts Supervisor.RunAsync in the background, and calls RunningProcess.StopAsync during host shutdown.

services.AddProcessKitGroup();

services.AddProcessKitHostedProcess(
    "worker",
    new Command("worker").Arg("--serve"),
    supervisor => supervisor
        .Restart(RestartPolicy.OnCrash)
        .OnRestart(e => metrics.Restarts.Add(1)));

services.ConfigureProcessKitHostedProcess("worker", o =>
{
    o.ShutdownGracePeriod = TimeSpan.FromSeconds(10);
});

These Hosting extensions preserve the same diagnostic contract: a null argument reports its public parameter name (services, name, command, configureSupervisor, or configure) from AddProcessKitHostedProcess, ConfigureProcessKitHostedProcess, and AddProcessKitHostedProcessHealthCheck.

Resolve HostedProcessService by the same key when you need the last SupervisionOutcome or stop outcome for health reporting. It also exposes live supervision telemetry — IsSupervisionActive, RestartCount, IsStormPaused — for anything that wants to observe the child without waiting for supervision to end (e.g. metrics, or the health check below).

Health-checking a hosted process

AddProcessKitHostedProcessHealthCheck(name) registers a keyed IHealthCheck (HostedProcessHealthCheck, same key as AddProcessKitHostedProcess) that maps the named hosted process's supervision state: Healthy while it is running (including restarting within policy), Degraded while the failure-storm guard (Supervisor.StormPause) is throttling restarts, and Unhealthy once supervision is not active (not started yet, or ended — an error, an exhausted restart budget, a permanent-failure give-up, or a stop-predicate match).

This is opt-in and stays in ProcessKit.Extensions.Hosting (not a separate package): its only extra dependency, Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions, is Abstractions-only — IHealthCheck / HealthCheckResult / HealthCheckRegistration, never the full Microsoft.Extensions.Diagnostics.HealthChecks package that supplies AddHealthChecks() / IHealthChecksBuilder / the concrete polling HealthCheckService. That package stays out of this one's dependency graph, so a consumer who never calls AddProcessKitHostedProcessHealthCheck never pulls it in either — but it also means this method cannot call AddHealthChecks() on your behalf. Wire the registered keyed check into your own health-checks pipeline (already referenced transitively via the ASP.NET Core shared framework in a web host; add Microsoft.Extensions.Diagnostics.HealthChecks explicitly in a Worker Service) with HealthCheckRegistration's factory overload:

services.AddProcessKitHostedProcess("worker", new Command("worker").Arg("--serve"));
services.AddProcessKitHostedProcessHealthCheck("worker");

services.AddHealthChecks().Add(
    new HealthCheckRegistration(
        "worker",
        sp => sp.GetRequiredKeyedService<HostedProcessHealthCheck>("worker"),
        failureStatus: null,
        tags: null));

For a complete runnable Generic Host example — self-hosted child process, restart policy, keyed health reporting through ILogger, and graceful Ctrl+C shutdown — see samples/CSharp.WorkerService.


Next: Platform support