子进程
这是整个 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.Ignore | fd 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 之间的宽限期,实际升级路径分平台:
- 非 Windows:先向进程发 SIGTERM;
- 等待
GraceMs; - 仍未退出则
Kill(entireProcessTree: true),整树带走。
Windows 上没有信号这一步,直接进入宽限等待再整树终止。
按整棵树终止而不是只杀直接子进程,是为了不留下孤儿——shell 起的子命令、ripgrep 的工作进程都在树里。
两个异常
| 异常 | 时机 |
|---|---|
SubprocessSpawnException | 还没拿到活句柄就失败了 |
SubprocessTeardownException | Provider 释放时未能汇合一棵或多棵自有进程树 |
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——因此环境擦洗、进程树终止、有界收集这些策略只有一份实现。