子进程

这是整个 harness 里最底层的执行原语:解析可执行文件,启动受管的进程树。 Shell 能力和文件搜索都建在它之上。对应实现:src/Tether.Subprocess/(seam)与 src/Tether.Subprocess.Local/(本地 Provider)。

这条 seam管什么

public interface ISubprocessRuntime
{
    ValueTask<string> ResolveExecutableAsync(
        string command,
        IReadOnlyDictionary<string, string?>? environment = null,
        CancellationToken cancellation = default);

    ISubprocessHandle Spawn(SubprocessSpawnSpec spec);
}

只有两件事:查可执行文件,起进程树。PTY 不属于这条 seam——交互式终端是另一个关注点,不在这里混进来。

Tether.Subprocess 与其它能力 seam 一样是零引用的定义程序集,因此换 Provider 不动消费者。

解析规则有三条,是为了避免歧义执行:

  • 绝对路径:校验其存在性;
  • 裸名字:在擦洗过的 PATH 里搜索;
  • 含路径分隔符的相对路径:直接拒绝。

规格必须完全指定

var spec = new SubprocessSpawnSpec(
    argv: ["rg", "--json", pattern, root],
    cwd: workspaceRoot,
    stdio: SubprocessStdio.Collect(maxBytes: 1 << 20, spillMaxBytes: 1 << 26),
    graceMs: 2000,
    cancellation: token,
    environment: new Dictionary<string, string?> { ["RIPGREP_CONFIG_PATH"] = null });

using var handle = runtime.Spawn(spec);

这条 seam不提供任何默认值

cwd、stdio、graceMs 全是必填,argv[0] 必须非空。 默认值属于上层策略,放在这里会让"到底用了什么配置"变得不可见。

Argv 永不经过 shell 解释——它是直接的 execve 风格参数数组。想要 shell 语义得走 Shell 能力。

stdio 处置

三个流各自独立指定。stdin 有三种:

模式行为
SubprocessStdinMode.Ignorefd 0 留在空设备上
SubprocessStdinMode.Pipe暴露可写的 stdin 管道
SubprocessStdinMode.Data(text)写入 UTF-8 文本后关闭 stdin

stdout / stderr 有三种:

模式行为
SubprocessOutputMode.Pipe暴露原始可读管道
SubprocessOutputMode.Inherit透传父进程的描述符
SubprocessOutputMode.Collect(maxBytes, spillMaxBytes?)缓冲有界尾部,可选溢写整条流

句柄上的流属性遵循”当且仅当”关系:StandardInput 只在以 pipe 模式启动时非空,StandardOutput / StandardError 同理;CollectedStdout / CollectedStderr 只在 collect 模式下非空。

有界收集与溢写

collect 模式解决的是”输出可能很大,但不能拖垮进程”。它有两层:

  • MaxBytes —— 内存里保留的尾部窗口上限,超出的从头部丢弃;
  • SpillMaxBytes —— 可选,把整条流同时写到临时文件。

增量读取是按偏移的:

var read = handle.CollectedStdout!.ReadFrom(offset);
offset = read.NextOffset;      // 下次从这里续读
if (read.Lossy) { /* 请求的偏移已滑出内存尾窗 */ }
if (read.SpillPath is { } path) { /* 完整流仍在这个文件里 */ }

Lossy 为真时,Text 给出的是当前保留的整个尾部,而不是从请求偏移开始的片段——因为那段已经没了。这让调用方能明确区分”读到了连续数据”和”中间丢了一截”。

溢写超限会被整体丢弃

一旦整条流超过 SpillMaxBytes,实现会删掉已写的溢写文件并停止后续溢写, SpillPath 随之变为空。所以 SpillPath 的语义是"完整流仍然完好时的路径", 而不是"曾经写过的路径"——半截的溢写文件比没有更危险。

溢写文件落在临时目录的 tether-subprocess/ 下,文件名带进程 id、流标签与随机后缀。

环境擦洗

每个由 harness 启动的子进程都走同一套环境策略:

// 环境 = 擦洗后的父环境,再合并显式条目
var env = EnvironmentScrub.ChildEnv(spec.Environment);

擦洗掉两类名字:

  • 名字里含 KEY、PASSWORD、SECRET、TOKEN 的(大小写不敏感);
  • 以 TETHER_ 开头的(该前缀保留给 harness 自己管理的子进程事实)。

然后显式条目合并进去,覆盖擦洗结果。这里有个关键约定:

显式条目的值含义
字符串刻意选入——即使名字像凭据也会被放进去
null墓碑——从环境里移除该名字

也就是说凭据默认不泄漏给子进程,需要传就必须显式写出来,这个动作本身就是审计点。Windows 上键的合并按大小写不敏感处理。

句柄与结束

public interface ISubprocessHandle
{
    int ProcessId { get; }                       // spawn 失败时为 -1
    Stream? StandardInput { get; }
    Stream? StandardOutput { get; }
    Stream? StandardError { get; }
    ISubprocessOutputReader? CollectedStdout { get; }
    ISubprocessOutputReader? CollectedStderr { get; }
    Task<SubprocessOutcome> Done { get; }
    void Terminate();
    ValueTask<bool> WaitForExitAsync(CancellationToken cancellation = default);
}

Done 在进程关闭时以退出事实完成,只在 spawn 级失败时才 reject。退出事实很朴素:

public sealed class SubprocessOutcome
{
    public int? ExitCode { get; }        // null = 被信号杀死
    public bool Succeeded => ExitCode == 0;
}

这一层不做超时与取消的分类

SubprocessOutcome 里没有"超时"或"被取消"这种判定。这条 seam只报告 进程怎么结束的客观事实,把"为什么"留给知道意图的上层。

Terminate() 启动树范围的终止,进程树消失后再调用是幂等的。WaitForExitAsync 的返回值区分两种情况:true 表示进程树确实退出了,false 表示先被取消。

终止升级

规格里的 GraceMs 是 SIGTERM 到 SIGKILL 之间的宽限期,实际升级路径分平台:

  1. 非 Windows:先向进程发 SIGTERM;
  2. 等待 GraceMs;
  3. 仍未退出则 Kill(entireProcessTree: true),整树带走。

Windows 上没有信号这一步,直接进入宽限等待再整树终止。

按整棵树终止而不是只杀直接子进程,是为了不留下孤儿——shell 起的子命令、ripgrep 的工作进程都在树里。

两个异常

异常时机
SubprocessSpawnException还没拿到活句柄就失败了
SubprocessTeardownExceptionProvider 释放时未能汇合一棵或多棵自有进程树

Provider 释放时会汇总失败:只有一个失败就直接包起来,多个失败包成 AggregateException 塞进内层,不丢信息。

Provider 与生命周期

public sealed class LocalSubprocessPlugin : Plugin
{
    protected override void Apply(Context context)
    {
        var runtime = new LocalSubprocessRuntime();
        context.Provide<ISubprocessRuntime>(runtime);
        context.Effect(runtime.DisposeAsync);
    }
}

运行时挂在插件作用域上,插件卸载时它负责把自有进程树收干净——这是注册即回收纪律在”外部资源”上的体现:进程不是托管对象,但同样必须随作用域回收。

谁在用它

消费者用途
Tether.Shell.Local起 bash / pwsh 解释器
Tether.Fs.Search跑打包的 ripgrep 做 glob / grep

两者都不自己碰 System.Diagnostics.Process,而是共用这条 seam——因此环境擦洗、进程树终止、有界收集这些策略只有一份实现。

Tether.Shell.Local 起 bash / pwsh 解释器 Tether.Fs.Search 跑打包的 ripgrep Tether.Subprocess + .Local 进程树 · 环境擦洗 · 有界收集与溢写 · 终止升级 两者都不直接使用 System.Diagnostics.Process,因此上面那四项策略只有一份实现、只需验证一次。
把进程执行收敛成一条 seam的收益是策略统一:凭据不会因为某个调用方忘了擦洗而泄漏给子进程。

下一步

在 GitHub 上编辑此页