package execenv // default_executor.go - Flyto 本地 CLI 模式的默认 Executor 实现. // // 零开销承诺: Command() 只做 Spec 到 *exec.Cmd 的字段映射, 不触发 // 任何系统调用. 所有 Process 方法直接转发给 *exec.Cmd 同名方法, // 零逻辑, 零内存分配. 通过 executor_bench_test.go 持续验证 < 5% // 开销差, 超过触发重新设计 (可能 Spec struct 太大导致值拷贝成本). // // 云端模式由 platform/common/internal/sandbox.Backend 替换此实现, 本地 // 零感知. 这是 platform sandbox DI 的唯一注入点. import ( "context" "io" "os" "os/exec" "strconv" "syscall" "time" ) // groupKillWaitDelay 是 IsolateProcessGroup 路径下 cmd.WaitDelay 的固定值. // 在 cmd.Cancel 发完 SIGKILL 后给 Wait 最多 200ms 清理时间, 然后强制返回. // 对齐原 plugin_tool.go 的 200ms, 防止 orphaned 进程让 Wait 无限挂住. const groupKillWaitDelay = 200 * time.Millisecond // DefaultExecutor 是本地 CLI 模式的默认 Executor 实现. // // Class 和 TenantID 在本地模式被忽略 - local CLI 信任用户, 不按 // Class 差异化隔离 (见 project_sandbox_local_vs_cloud memory). // 行业对齐: Claude Code / Cursor / Aider / VS Code tasks 全都如此. type DefaultExecutor struct{} // Command 零开销包装 exec.CommandContext + Spec 字段映射. // // 不返回 error: *exec.Cmd 的错误 (Path 不可达 / fork 失败 / 资源不足) // 全都在 Start() 时才报告, 和 Go 标库语义一致. // // Spec 字段映射策略: // - Env/Dir/Stdin: 原样映射 // - Stdout/Stderr: Writer 模式直接赋给 cmd.Stdout/Stderr (nil 保持默认) // - IsolateProcessGroup: 映射为 Setpgid + Pgid 0 (和 bash/bash_background // 现有写法一致). Linux/Mac only; Windows 不支持此字段会编译失败, 对齐 // Flyto core 当前的 POSIX-only 约束. // - Class/TenantID: 本地模式忽略 func (DefaultExecutor) Command(ctx context.Context, spec Spec) Process { cmd := exec.CommandContext(ctx, spec.Path, spec.Args...) cmd.Env = mapToEnvSlice(spec.Env) cmd.Dir = spec.WorkDir cmd.Stdin = spec.Stdin cmd.Stdout = spec.Stdout cmd.Stderr = spec.Stderr if spec.IsolateProcessGroup { cmd.SysProcAttr = &syscall.SysProcAttr{ Setpgid: true, Pgid: 0, } // IsolateProcessGroup 不只 Setpgid, 还要让 ctx 取消时 kill 整个 // group 而不是只 kill 直接子进程 (后者是 exec.CommandContext 的 // 默认行为, 会让 shell 派生的孙进程孤儿化继续跑). // // cmd.Cancel + cmd.WaitDelay 是 Go 1.20+ 的标准生命周期 hook: // - Cancel 在 ctx.Done 时被调用一次 // - WaitDelay 限定 Wait 在 Cancel 后最多等多久再强制返回 // // 忽略 syscall.Kill 返回值: 进程组可能自然退出已不存在 (ESRCH), // 不是错误. 和原 plugin_tool_unix.go killProcessGroup 行为一致. // // 这里闭包捕获 cmd, 不 capture p (cmdProcess 还没构造). Cancel // 可能在 Process.Start 之前被调用 (不太可能但防御性检查), 此时 // cmd.Process 为 nil, 直接返回 nil. cmd.Cancel = func() error { if cmd.Process == nil { return nil } _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) return nil } cmd.WaitDelay = groupKillWaitDelay } return &cmdProcess{cmd: cmd, isolateGroup: spec.IsolateProcessGroup} } // cmdProcess 包装 *exec.Cmd 实现 Process interface. // 所有方法直接转发, 零逻辑. 这是"零开销包装"的核心载体. // // isolateGroup 记录创建时的 Spec.IsolateProcessGroup 值, 让 SignalGroup // 能判断子进程是不是独立 pgid leader — 不是的话必须退化为单进程 Signal, // 否则对父 pgid 发信号会把 engine 自己杀掉. type cmdProcess struct { cmd *exec.Cmd isolateGroup bool } func (p *cmdProcess) Start() error { return p.cmd.Start() } func (p *cmdProcess) Wait() error { return p.cmd.Wait() } func (p *cmdProcess) Run() error { return p.cmd.Run() } func (p *cmdProcess) StdinPipe() (io.WriteCloser, error) { return p.cmd.StdinPipe() } func (p *cmdProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() } func (p *cmdProcess) StderrPipe() (io.ReadCloser, error) { return p.cmd.StderrPipe() } // Signal 转发信号到底层 *os.Process. 未 Start 或已 Wait 时返回 error // (由底层 os.Process.Signal 决定具体错误). func (p *cmdProcess) Signal(sig os.Signal) error { if p.cmd.Process == nil { return os.ErrProcessDone } return p.cmd.Process.Signal(sig) } // Kill 幂等: 未 Start 或 Process 已释放时返回 nil, 不 panic. // // *exec.Cmd.Process 在 Start() 之前是 nil, Wait() 之后虽仍非 nil // 但底层 OS process 已回收, Kill 会返回 os: process already finished. // 我们只 guard nil 情况; 已 Wait 的 double-kill 让底层 error 冒泡, // 让调用方 (如果在意) 自己处理, 大多数 caller 都是 defer p.Kill() // 不 care 返回值. func (p *cmdProcess) Kill() error { if p.cmd.Process == nil { return nil } return p.cmd.Process.Kill() } // SignalGroup 向子进程所在的 process group 发送信号. // // 前置: 创建时设 Spec.IsolateProcessGroup=true 让 pgid = pid. 否则精确 // 退化为单进程 Signal — 不对父 pgid 发信号, 那会把 engine 宿主一起杀掉. // // 实现路径: // // 1. 未 Start: 返回 os.ErrProcessDone (和 Signal 对齐). // 2. isolateGroup=false: 退化为 p.cmd.Process.Signal(sig). // 3. sig 不是 syscall.Signal 类型: 退化为 Signal 单进程 (os 自带的 // 非标准信号如 os.Interrupt 在 Unix 下其实就是 syscall.SIGINT, // 这里还是做类型断言保险, 非 syscall.Signal 走单进程路径). // 4. 常规路径: syscall.Kill(-pid, sig). 负 pid 是 Unix pgid 语义, // 作用于整个 process group, 和原 bash.go L783/L793 / plugin_tool_unix.go // L50 的直写 syscall.Kill 行为完全一致. // // Windows: 当前 core 已是 POSIX-only 约束 (default_executor.go L37 注释), // syscall.Kill 在 Windows 下不存在, 本文件在 Windows 根本不编译. Job // Object 方案未来再做, 对齐 plugin_tool_windows.go:38 的 windows fallback. func (p *cmdProcess) SignalGroup(sig os.Signal) error { if p.cmd.Process == nil { return os.ErrProcessDone } if !p.isolateGroup { return p.cmd.Process.Signal(sig) } sysSig, ok := sig.(syscall.Signal) if !ok { return p.cmd.Process.Signal(sig) } return syscall.Kill(-p.cmd.Process.Pid, sysSig) } func (p *cmdProcess) Output() ([]byte, error) { return p.cmd.Output() } func (p *cmdProcess) CombinedOutput() ([]byte, error) { return p.cmd.CombinedOutput() } // ID 返回子进程的字符串标识符. 本地模式 = Pid. 未 Start 时返回空串. // // caller 必须把它当 opaque string 用于日志/任务追踪, 不应解析回 pid // 做进程操作 — 那违反 "不锁死 POSIX 进程模型" 红线. 云端 backend // 会返回 microVM_id 之类非数字字符串. func (p *cmdProcess) ID() string { if p.cmd.Process == nil { return "" } return strconv.Itoa(p.cmd.Process.Pid) } // mapToEnvSlice 把 map[string]string 转成 []string ("KEY=VALUE" 形式) // 用于赋给 *exec.Cmd.Env. // // 陷阱: nil map 返回 nil, 让 *exec.Cmd 走默认行为 (继承 os.Environ 全部). // 调用方如果要清空 env, 必须传 empty-but-non-nil map. 这个陷阱在 // executor.go 的 Spec.Env 注释里有详细警告 + 强制约定 (永远走 // execenv helper 函数, 不自己写 nil 或 map{}). // // 为什么不在此函数加 "nil = 空 env" 的自创语义: (1) 和 Go 标库 // 不一致, 未来维护者 (包括 AI) 需要先学私有约定 (2) Spec.Env 的 // 语义应和 *exec.Cmd.Env 对齐, 方便 *exec.Cmd 之外的 backend // (如 E2B) 也用同一套心智模型. func mapToEnvSlice(m map[string]string) []string { if m == nil { return nil } out := make([]string, 0, len(m)) for k, v := range m { out = append(out, k+"="+v) } return out }