From 509c009b71f7d177d48c19378592fd869196ca50 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:31:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20uv=20=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E9=80=80=E5=87=BA=E5=90=8E=E4=BB=8D=E8=A2=AB=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E7=9A=84=E9=97=AE=E9=A2=98=20(T5.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...33\347\250\213\347\272\263\347\256\241.md" | 5 + ...33\347\250\213\347\272\263\347\256\241.md" | 33 +++ ...73\345\212\241\346\213\206\345\210\206.md" | 8 + ...66\346\236\204\350\256\276\350\256\241.md" | 2 +- internal/process/managed_other.go | 8 +- internal/process/managed_windows.go | 20 +- internal/uv/runner.go | 21 ++ internal/uv/runner_isolation_windows_test.go | 222 ++++++++++++++++++ internal/uv/runner_windows.go | 2 +- 9 files changed, 314 insertions(+), 7 deletions(-) create mode 100644 "doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" create mode 100644 "doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" create mode 100644 internal/uv/runner_isolation_windows_test.go diff --git "a/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" "b/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" new file mode 100644 index 0000000..11b33eb --- /dev/null +++ "b/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" @@ -0,0 +1,5 @@ +# T5.18 实施计划 + +1. 在 `internal/uv/runner_isolation_windows_test.go` 固定短进程已退出的原生 API 顺序,并从实际 `UVRunner.Run` 的 Job 边界复现错误。固定测试名为 `TestRunner_ExitedProcessCannotJoinJob`、`TestRunner_AssignsBeforeExecuting`。基线执行 `go test ./internal/uv -run 'TestRunner_(ExitedProcessCannotJoinJob|AssignsBeforeExecuting)$' -v -count=1`,前者通过,后者因子进程已退出、隔离失败而失败。 +2. 在 `internal/uv/runner_windows.go` 挂起创建,`internal/uv/runner.go` 分配后恢复,`internal/process/managed_windows.go` 复用现有恢复线程实现;保留非 Windows 编译边界。补恢复前取消验证,复用现有执行器检查。 +3. 运行 gofmt、vet、build、完整测试、protocol 100 轮和可用时的 race,复核真实 uv 探针。按实际结果更新设计;提交时显式暂存本任务源码、测试和文档,不改其他在途任务。代码与进度分别提交。提交后独立复核,不合入或推送其他分支。 diff --git "a/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" new file mode 100644 index 0000000..303f8cb --- /dev/null +++ "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" @@ -0,0 +1,33 @@ +# T5.18 短命 uv 进程纳管 + +## 问题与证据 + +`UVRunner.Run` 原先先执行 `command.Start()`,再创建 Job 并按 PID 分配。Windows 原生检查固定子进程成功退出、父进程尚未 `Wait` 的顺序后,`OpenProcess` 成功、`WaitForSingleObject` 返回 `WAIT_OBJECT_0`、退出码为 0,而 `AssignProcessToJobObject` 返回 `ERROR_ACCESS_DENIED (5)`。在实际 `UVRunner.Run` 的 Job 边界延后 Assign,同样把成功的版本探针报告为 `uv.verify` 隔离失败。 + +这确认了短进程退出早于 Assign 的竞态。用户原始日志只有 `process_isolation_failed`,没有失败 API 或 Windows 错误号,不能据此断言用户现场必为本竞态。v0.1.24、v0.1.28、v0.1.29 的执行顺序相同,本问题并非 v0.1.29 新引入。 + +## 方案与边界 + +一次性 uv 同长驻进程一样以 `CREATE_SUSPENDED | CREATE_NO_WINDOW` 创建。Job 分配成功后才恢复初始线程;恢复逻辑复用 `process` 现有线程查找、恢复和句柄关闭实现。`UVRunner` 继续拥有 `exec.Cmd`、原始双流管道、输出上限、逐行回调、Wait 和取消,不改为 `ManagedProcess`,避免改变一次性命令的输出及退出码语义。 + +Job 创建、分配、线程恢复任一步失败均结束并等待本次进程,保留真实错误,不恢复未纳管进程。取消在恢复前再次检查,取消不允许子进程开始业务工作。恢复阶段的本地诊断沿用已有隔离诊断,操作名为 `resume_process`;不新增协议字段,不改变错误码四元组,不影响宿主控制台或网络策略。 + +## 验收 + +实际 Windows API 检查保留已退出进程分配失败的证据;实际 `UVRunner` 检查在 Assign 前进程尚未执行、恢复后双流输出完整且退出码正确。复用已有非零退出、回调失败、输出上限、超时、取消和进程树测试,新增恢复前取消检查。检查使用进程信号和有界等待,不以 sleep 安排顺序。 + +## 本地验收,2026-11-12 + +环境为 Windows amd64、Go 1.26.11。检查源码与 `v0.1.24`、`v0.1.28`、`v0.1.29`,历史执行器和 Job 文件在这些 tag 间无差异。以下命令均在独立 worktree 执行,原始开机/休眠现场未复测。 + +- `go test ./internal/uv -run 'TestRunner_(ExitedProcessCannotJoinJob|AssignsBeforeExecuting)$' -v -count=1`:原始执行器通过 Go overlay 复跑退出 1,日志给出原生 API 错误 5 和成功退出码 0;修改后退出 0,Assign 前为 `WAIT_TIMEOUT` / `STILL_ACTIVE`,随后双流输出完整、退出码 0。 +- `go test ./internal/uv -run 'TestRunner_(ExitedProcessCannotJoinJob|AssignsBeforeExecuting|CancelBeforeResumeDoesNotExecute)$' -v -count=100`:退出 0。`go test ./internal/uv -run '^TestRunner_CancelReapsDescendants$' -v -count=20`:退出 0,取消后通过原生进程句柄确认后代退出。 +- 作者隔离探针用同一真实 `UVRunner.Run` 执行固定 uv 0.12.3 和本机 uv 0.13.7 的 `--version`。在 Assign 边界等待快进程退出时,原始执行器均退出 1、报告隔离失败;修改后均有完整真实版本输出、退出码 0。探针和原始源码 overlay 留在本地证据目录,没有加入默认测试或修改安装版。 +- `go test ./internal/uv -count=1` 退出 0;执行器、原有 Job、M5 事务定向检查退出 0,覆盖非零退出、无窗口、参数、双流、输出边界、回调失败、取消和进程树回收。定向检查明确排除了下面已经在基线复现的 breakaway 夹具。 +- `gofmt -l .` 无输出;`go vet ./...`、`go build -buildvcs=false ./...`、`git diff --check` 均退出 0;`go test ./internal/protocol -count=100` 退出 0。 +- 完整 `go test ./... -count=1` 退出 1:`lock` 的 SUBST W: 未就绪、`process` 的 breakaway 孙进程不属于任何 Job 断言失败,均在未修改源码的定向检查中复现。另有 `cli` 的 TempDir 清理偶发目录非空,原始执行器定向十轮和修改后定向均通过,未归因于本改动。其余包通过,不能表述为完整门禁通过。 +- `go test -race ./... -count=1` 退出 2,提示需要 cgo。本机 `CGO_ENABLED=0` 且 PATH 无 GCC,race 未执行,仍需在具备工具链的 Windows 环境验证。 + +本次只复用既有结构化错误、本地诊断和 Sentry 结果归属;`resume_process` 为现有 operation 字段的稳定值,不新增采集字段。主仓恢复修复可以独立交付,不依赖本 Runtime 的未发布制品。 + +实际证据文件包括 `isolation-baseline-final.log`、`relevant-tests-final.log`、`isolation-repeat.log`、`descendant-cancel.log`、`uv0123-baseline.log`、`uv0123-fixed.log`、`full-tests.log`、`breakaway-baseline.log`、`subst-unmodified.log`、`race.log`。用户现场仍需使用发布后的 Runtime 收集具体失败 API 和 Windows 错误号,才能判断是否同一原因。 diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index e67e27c..718ed39 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -627,6 +627,12 @@ D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版 ### - [x] M5 uv、Python 与依赖(internal/uv) ✅ 2026-10-22 `6fb4b81` +- [ ] **T5.18 短命 uv 进程纳管**(S) 🚧 + - 依赖:T5.2、T6.1;来源:v5.8.0 启动失败调查的 Windows 原生复现。 + - 内容:一次性 uv 挂起创建、分配 Job 后恢复,复用长驻进程的线程恢复实现;保持输出、退出码、取消和隔离诊断契约。 + - 验收:固定短进程先退出再 Assign 的 API 证据、执行前纳管红绿灯、恢复前取消、现有执行器及标准门;不能据此认定用户现场根因。 + - 设计与计划:[设计](./current/M5/设计-T5.18-短命uv进程纳管.md)、[计划](./current/M5/计划-T5.18-短命uv进程纳管.md)。 + - [ ] **T5.17 uv 执行失败诊断与失败日志落盘**(S) 🚧 - 依赖:T5.1、T5.2、T5.16;来源:[AUTO-MAS CNB #263](https://cnb.cool/AUTO-MAS-Project/AUTO-MAS/-/issues/263)。 - 内容:uv 准备包装保留底层结构化细节;命令资源收尾后以同一命令和操作编号打开终态日志器,记录最终失败,避免写入已关闭的日志器。 @@ -1697,6 +1703,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 ## 7. 变更记录 +2026-11-12:登记 T5.18。Windows 原生同步检查确认 uv 短进程成功退出后 Assign 返回错误 5;实际执行器复现同类隔离失败。先修订一次性 uv 也须挂起纳管的架构约束,再复用线程恢复实现,不推断用户现场必为此竞态。 + 2026-11-12:按用户要求在 PR #35 登记 T10.18,升级 Go 1.26.11 解除标准库漏洞门禁;保留语言版本、依赖和所有检查,以真实漏洞扫描及最新 CI 验收。 2026-11-12:按用户继续补齐的要求,T12.11 增补统一会话日志、Sentry 关联回执、状态环境和子类诊断;先修订设计、计划与架构,保持协议及错误优先级。 diff --git "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" index d92f8b8..4ea5570 100644 --- "a/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" +++ "b/doc/\346\236\266\346\236\204\350\256\276\350\256\241.md" @@ -1588,7 +1588,7 @@ type UVResult struct { 2. 设置工作目录和受控环境变量; 3. 捕获并逐行转发 stdout/stderr; 4. 应用超时、取消和镜像重试策略; -5. 以挂起状态创建长驻 `uv run`,分配到启用 `KILL_ON_JOB_CLOSE` 的 Windows Job Object 后再恢复; +5. 所有 uv 命令均以挂起状态创建,分配到启用 `KILL_ON_JOB_CLOSE` 的 Windows Job Object 后再恢复。一次性 `uv --version` 同样遵守此顺序,避免短进程先成功退出、随后 Assign 被 Windows 拒绝; 6. 将退出码映射为 Runtime 结构化错误; 7. 保留完整原始日志,不通过解析 uv 文案判断操作成功; 8. 记录 uv 版本、Python 版本、项目分支和 Commit。 diff --git a/internal/process/managed_other.go b/internal/process/managed_other.go index 68047f9..8cb89f5 100644 --- a/internal/process/managed_other.go +++ b/internal/process/managed_other.go @@ -2,9 +2,15 @@ package process -import "context" +import ( + "context" + "os" +) // StartManaged 在非 Windows 平台失败关闭,绝不降级为普通子进程。 func StartManaged(context.Context, StartSpec) (*ManagedProcess, error) { return nil, ErrUnsupported } + +// ResumeSuspended 保留挂起恢复的平台边界,不允许无监督降级。 +func ResumeSuspended(*os.Process) error { return ErrUnsupported } diff --git a/internal/process/managed_windows.go b/internal/process/managed_windows.go index 0ce919b..75edd52 100644 --- a/internal/process/managed_windows.go +++ b/internal/process/managed_windows.go @@ -170,13 +170,25 @@ func startManagedWindows( if err := ctx.Err(); err != nil { return cleanupStarted(err) } - thread, err := operations.initialThread(uint32(processValue.Pid)) + if err := resumeInitialThread(uint32(processValue.Pid), operations); err != nil { + return cleanupStarted(err) + } + return ownManagedProcess(ctx, spec.Sink, processValue, job, stdoutRead, stderrRead), nil +} + +// ResumeSuspended 恢复已纳入 Job 的挂起进程;失败时由进程 owner 负责终止并等待。 +func ResumeSuspended(processValue *os.Process) error { + return resumeInitialThread(uint32(processValue.Pid), defaultWindowsStartOperations()) +} + +func resumeInitialThread(pid uint32, operations windowsStartOperations) error { + thread, err := operations.initialThread(pid) if err != nil { var threadCloseErr error if thread != 0 { threadCloseErr = operations.closeHandle(thread) } - return cleanupStarted(errors.Join(fmt.Errorf("find managed process initial thread: %w", err), threadCloseErr)) + return errors.Join(fmt.Errorf("find managed process initial thread: %w", err), threadCloseErr) } previous, resumeErr := operations.resumeThread(thread) threadCloseErr := operations.closeHandle(thread) @@ -187,9 +199,9 @@ func startManagedWindows( if resumeErr != nil { resumeErr = fmt.Errorf("resume managed process initial thread: %w", resumeErr) } - return cleanupStarted(errors.Join(resumeErr, threadCloseErr)) + return errors.Join(resumeErr, threadCloseErr) } - return ownManagedProcess(ctx, spec.Sink, processValue, job, stdoutRead, stderrRead), nil + return nil } func validateStartSpec(ctx context.Context, spec StartSpec) error { diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 535665b..8cdadd8 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -294,6 +294,27 @@ func (r *UVRunner) Run( return UVResult{}, failure } } + // 挂起创建阻止短命探针先于 Assign 退出,也阻止未纳管的后代逃出 Job。 + // 取消先于恢复检查,避免退出期间再开始 uv 的业务工作。 + if err := ctx.Err(); err != nil { + _ = command.Process.Kill() + _ = command.Wait() + if job != nil { + _ = job.Close() + } + return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, err) + } + if err := process.ResumeSuspended(command.Process); err != nil { + _ = command.Process.Kill() + waitErr := command.Wait() + if job != nil { + _ = job.Close() + } + failure := newError(protocol.CodeUVExecFailed, options.Stage, "uv 进程隔离失败", + map[string]any{"failureKind": "process_isolation_failed"}, err) + failure.diagnostics = isolationDiagnostics(UVResult{ExitCode: exitCode(waitErr), Duration: clock().Sub(started)}, resolved, "resume_process", err) + return UVResult{}, failure + } jobStop := make(chan struct{}) jobDone := make(chan struct{}) if job != nil { diff --git a/internal/uv/runner_isolation_windows_test.go b/internal/uv/runner_isolation_windows_test.go new file mode 100644 index 0000000..03d4415 --- /dev/null +++ b/internal/uv/runner_isolation_windows_test.go @@ -0,0 +1,222 @@ +//go:build windows + +package uv + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "golang.org/x/sys/windows" + + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/process" + "github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/internal/protocol" +) + +// TestRunner_ExitedProcessCannotJoinJob 固定子进程已退出、尚未 Wait 的顺序,验证真实 Windows API。 +func TestRunner_ExitedProcessCannotJoinJob(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, testExecutable(t), "-test.run=^TestFakeUVProcess$") + command.Env = append(os.Environ(), "FAKE_UV_STDOUT=uv 0.12.3\n") + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = command.Process.Kill(); _ = command.Wait() }) + var status, code uint32 + var probeErr error + if err := command.Process.WithHandle(func(handle uintptr) { + status, probeErr = windows.WaitForSingleObject(windows.Handle(handle), 5000) + if probeErr == nil { + probeErr = windows.GetExitCodeProcess(windows.Handle(handle), &code) + } + }); err != nil || probeErr != nil || status != windows.WAIT_OBJECT_0 || code != 0 { + t.Fatalf("child exit probe = status %d code %d error %v / %v, want signalled exit 0", status, code, err, probeErr) + } + job, err := process.NewJob() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = job.Close() }) + // 先确认 OpenProcess 成功,避免把 PID/访问句柄错误误记为 AssignProcessToJobObject 错误。 + handle, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(command.Process.Pid)) + if err != nil { + t.Fatal(err) + } + rawJob, err := windows.CreateJobObject(nil, nil) + if err != nil { + _ = windows.CloseHandle(handle) + t.Fatal(err) + } + t.Cleanup(func() { _ = windows.CloseHandle(rawJob) }) + apiErr := windows.AssignProcessToJobObject(rawJob, handle) + if err := windows.CloseHandle(handle); err != nil { + t.Fatal(err) + } + assignErr := job.Assign(uint32(command.Process.Pid)) + var errno syscall.Errno + if !errors.As(apiErr, &errno) { + t.Fatalf("AssignProcessToJobObject error = %v, want Windows errno", apiErr) + } + t.Logf("OpenProcess succeeded; WaitForSingleObject=%d GetExitCodeProcess=%d AssignProcessToJobObject windowsError=%d Job.Assign=%v", status, code, errno, assignErr) + if !errors.Is(apiErr, windows.ERROR_ACCESS_DENIED) || !errors.Is(assignErr, windows.ERROR_ACCESS_DENIED) { + t.Fatalf("assign exited child = API %v wrapper %v, want ERROR_ACCESS_DENIED (5)", apiErr, assignErr) + } +} + +type observedIsolationJob struct { + process.Job + t *testing.T +} + +func (j observedIsolationJob) Assign(pid uint32) error { + j.t.Helper() + handle, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return err + } + status, waitErr := windows.WaitForSingleObject(handle, 200) + var code uint32 + codeErr := windows.GetExitCodeProcess(handle, &code) + closeErr := windows.CloseHandle(handle) + j.t.Logf("before Job.Assign: pid=%d WaitForSingleObject=%d exitCode=%d", pid, status, code) + if waitErr != nil || codeErr != nil || closeErr != nil { + return errors.Join(waitErr, codeErr, closeErr) + } + if status != uint32(windows.WAIT_TIMEOUT) { + j.t.Errorf("child already executed before Job.Assign: wait=%d exitCode=%d, want suspended", status, code) + } + return j.Job.Assign(pid) +} + +func TestRunner_AssignsBeforeExecuting(t *testing.T) { + runner := newTestRunner(t) + runner.newJob = func() (runnerJob, error) { + job, err := process.NewJob() + return observedIsolationJob{Job: job, t: t}, err + } + result, err := runner.Run(t.Context(), []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{ + Stage: protocol.StageUVVerify, + Environment: map[string]string{"FAKE_UV_STDOUT": "uv 0.12.3\n", "FAKE_UV_STDERR": "probe stderr\n"}, + }) + if err != nil || result.ExitCode != 0 || result.Stdout != "uv 0.12.3\n" || result.Stderr != "probe stderr\n" { + t.Fatalf("Run() = %+v, %v, want complete output and exit 0", result, err) + } +} + +type cancelBeforeResumeJob struct { + process.Job + cancel context.CancelFunc + pid uint32 + handle windows.Handle +} + +func (j *cancelBeforeResumeJob) Assign(pid uint32) error { + j.pid = pid + var err error + j.handle, err = windows.OpenProcess(windows.SYNCHRONIZE, false, pid) + if err != nil { + return err + } + if err := j.Job.Assign(pid); err != nil { + return err + } + j.cancel() + return nil +} + +func TestRunner_CancelBeforeResumeDoesNotExecute(t *testing.T) { + runner := newTestRunner(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + job := &cancelBeforeResumeJob{cancel: cancel} + runner.newJob = func() (runnerJob, error) { + var err error + job.Job, err = process.NewJob() + return job, err + } + recordPath := filepath.Join(t.TempDir(), "must-not-exist.txt") + _, err := runner.Run(ctx, []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{ + Stage: protocol.StageUVVerify, Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }) + if job.handle == 0 { + t.Fatal("child handle was not captured at assignment") + } + t.Cleanup(func() { _ = windows.CloseHandle(job.handle) }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + if _, statErr := os.Stat(recordPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("child executed while cancelling: marker error = %v", statErr) + } + status, waitErr := windows.WaitForSingleObject(job.handle, 0) + if waitErr != nil || status != windows.WAIT_OBJECT_0 { + t.Fatalf("child %d after Run = wait %d error %v, want confirmed exited", job.pid, status, waitErr) + } +} + +func TestRunner_CancelReapsDescendants(t *testing.T) { + runner := newTestRunner(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + var descendant windows.Handle + _, err := runner.Run(ctx, []string{"-test.run=^TestRunnerTreeProcess$"}, RunOptions{ + Stage: protocol.StageUVVerify, Environment: map[string]string{"FAKE_UV_TREE": "root"}, + Line: func(_ context.Context, stream, line string) error { + if stream != "stdout" || !strings.HasPrefix(line, "descendant=") { + return nil + } + pid, err := strconv.ParseUint(strings.TrimPrefix(line, "descendant="), 10, 32) + if err != nil { + return err + } + descendant, err = windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err == nil { + cancel() + } + return err + }, + }) + if descendant == 0 { + t.Fatalf("descendant handle was not captured: %v", err) + } + t.Cleanup(func() { _ = windows.CloseHandle(descendant) }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Run() error = %v, want context.Canceled", err) + } + status, waitErr := windows.WaitForSingleObject(descendant, 5000) + if waitErr != nil || status != windows.WAIT_OBJECT_0 { + t.Fatalf("descendant after cancellation = wait %d error %v, want confirmed exited", status, waitErr) + } +} + +// TestRunnerTreeProcess 提供真实父子进程,输出 PID 后由 Job 取消回收。 +func TestRunnerTreeProcess(t *testing.T) { + role := os.Getenv("FAKE_UV_TREE") + if role == "" { + return + } + if role == "root" { + child := exec.CommandContext(t.Context(), testExecutable(t), "-test.run=^TestRunnerTreeProcess$") + child.Env = append(os.Environ(), "FAKE_UV_TREE=descendant") + child.Stdout = os.Stdout + child.Stderr = os.Stderr + if err := child.Start(); err != nil { + t.Fatal(err) + } + if _, err := os.Stdout.WriteString("descendant=" + strconv.Itoa(child.Process.Pid) + "\n"); err != nil { + t.Fatal(err) + } + } + timer := time.NewTimer(time.Minute) + defer timer.Stop() + <-timer.C + os.Exit(0) +} diff --git a/internal/uv/runner_windows.go b/internal/uv/runner_windows.go index d94ea4b..affcdae 100644 --- a/internal/uv/runner_windows.go +++ b/internal/uv/runner_windows.go @@ -13,6 +13,6 @@ func configureUVCommand(command *exec.Cmd) { // 版本和 doctor 探针同样从桌面宿主启动,必须在创建进程时禁止分配控制台。 command.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, - CreationFlags: windows.CREATE_NO_WINDOW, + CreationFlags: windows.CREATE_SUSPENDED | windows.CREATE_NO_WINDOW, } } From 12cd423e524bd885476792b57250088fb2de5859 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:32:33 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20uv=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=97=AE=E9=A2=98=E7=9A=84=E5=A4=8D=E7=8E=B0=E5=92=8C?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" index 718ed39..b9d5800 100644 --- "a/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" +++ "b/doc/\344\273\273\345\212\241\346\213\206\345\210\206.md" @@ -627,11 +627,12 @@ D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版 ### - [x] M5 uv、Python 与依赖(internal/uv) ✅ 2026-10-22 `6fb4b81` -- [ ] **T5.18 短命 uv 进程纳管**(S) 🚧 +- [ ] **T5.18 短命 uv 进程纳管**(S) 🚧 2026-11-12 本地实现 `afd44bf` - 依赖:T5.2、T6.1;来源:v5.8.0 启动失败调查的 Windows 原生复现。 - 内容:一次性 uv 挂起创建、分配 Job 后恢复,复用长驻进程的线程恢复实现;保持输出、退出码、取消和隔离诊断契约。 - 验收:固定短进程先退出再 Assign 的 API 证据、执行前纳管红绿灯、恢复前取消、现有执行器及标准门;不能据此认定用户现场根因。 - 设计与计划:[设计](./current/M5/设计-T5.18-短命uv进程纳管.md)、[计划](./current/M5/计划-T5.18-短命uv进程纳管.md)。 + - 本地证据:Windows 原生错误 5、固定 uv 0.12.3 和实际执行器先红后绿;竞态与协议各 100 轮、后代取消 20 轮、uv 全包及格式/vet/build 通过。完整本机测试仍有已在基线复现的 SUBST 与 breakaway 夹具失败,另有临时目录清理波动;缺少 cgo/GCC,race 未执行。待后续 Windows 完整门禁和 PR 合入,不提前勾选完成。 - [ ] **T5.17 uv 执行失败诊断与失败日志落盘**(S) 🚧 - 依赖:T5.1、T5.2、T5.16;来源:[AUTO-MAS CNB #263](https://cnb.cool/AUTO-MAS-Project/AUTO-MAS/-/issues/263)。 @@ -1705,6 +1706,8 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已 2026-11-12:登记 T5.18。Windows 原生同步检查确认 uv 短进程成功退出后 Assign 返回错误 5;实际执行器复现同类隔离失败。先修订一次性 uv 也须挂起纳管的架构约束,再复用线程恢复实现,不推断用户现场必为此竞态。 +2026-11-12:T5.18 本地实现 `afd44bf`,保留原始命令管道与取消所有权,复用长驻启动的初始线程恢复。原生 API、固定 uv 与执行器红绿灯及定向重复通过;完整门禁的环境失败和 race 工具链缺口见设计验收,不宣称已修复用户原始开机/休眠根因。 + 2026-11-12:按用户要求在 PR #35 登记 T10.18,升级 Go 1.26.11 解除标准库漏洞门禁;保留语言版本、依赖和所有检查,以真实漏洞扫描及最新 CI 验收。 2026-11-12:按用户继续补齐的要求,T12.11 增补统一会话日志、Sentry 关联回执、状态环境和子类诊断;先修订设计、计划与架构,保持协议及错误优先级。 From 1ef8ac3ce0469fdb1c4c580d7052258d7a69cba8 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:40:10 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E5=85=B3=E9=97=AD?= =?UTF-8?q?=20uv=20=E6=97=B6=E5=87=BA=E9=94=99=E7=9A=84=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=20(T5.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...33\347\250\213\347\272\263\347\256\241.md" | 2 ++ internal/uv/runner.go | 16 +++++---- internal/uv/runner_isolation_windows_test.go | 33 +++++++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git "a/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" index 303f8cb..f4b79ed 100644 --- "a/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" +++ "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" @@ -31,3 +31,5 @@ Job 创建、分配、线程恢复任一步失败均结束并等待本次进程 本次只复用既有结构化错误、本地诊断和 Sentry 结果归属;`resume_process` 为现有 operation 字段的稳定值,不新增采集字段。主仓恢复修复可以独立交付,不依赖本 Runtime 的未发布制品。 实际证据文件包括 `isolation-baseline-final.log`、`relevant-tests-final.log`、`isolation-repeat.log`、`descendant-cancel.log`、`uv0123-baseline.log`、`uv0123-fixed.log`、`full-tests.log`、`breakaway-baseline.log`、`subst-unmodified.log`、`race.log`。用户现场仍需使用发布后的 Runtime 收集具体失败 API 和 Windows 错误号,才能判断是否同一原因。 + +复审发现恢复前取消和恢复失败的新增分支丢弃了清理错误,现用既有错误链保留 Kill、Wait、Job.Close 和原始原因。`TestRunner_CancelBeforeResumePreservesCleanupError` 在修正前失败、修正后通过;真实 Job 纳管后取消,并在实际关闭后注入清理错误,确认取消与清理原因均可追溯。修正后的完整 uv 包、vet 和 build 再次通过。 diff --git a/internal/uv/runner.go b/internal/uv/runner.go index 8cdadd8..a3fe2bb 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -297,21 +297,23 @@ func (r *UVRunner) Run( // 挂起创建阻止短命探针先于 Assign 退出,也阻止未纳管的后代逃出 Job。 // 取消先于恢复检查,避免退出期间再开始 uv 的业务工作。 if err := ctx.Err(); err != nil { - _ = command.Process.Kill() - _ = command.Wait() + killErr := command.Process.Kill() + waitErr := command.Wait() + var closeErr error if job != nil { - _ = job.Close() + closeErr = job.Close() } - return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, err) + return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, errors.Join(err, killErr, waitErr, closeErr)) } if err := process.ResumeSuspended(command.Process); err != nil { - _ = command.Process.Kill() + killErr := command.Process.Kill() waitErr := command.Wait() + var closeErr error if job != nil { - _ = job.Close() + closeErr = job.Close() } failure := newError(protocol.CodeUVExecFailed, options.Stage, "uv 进程隔离失败", - map[string]any{"failureKind": "process_isolation_failed"}, err) + map[string]any{"failureKind": "process_isolation_failed"}, errors.Join(err, killErr, waitErr, closeErr)) failure.diagnostics = isolationDiagnostics(UVResult{ExitCode: exitCode(waitErr), Duration: clock().Sub(started)}, resolved, "resume_process", err) return UVResult{}, failure } diff --git a/internal/uv/runner_isolation_windows_test.go b/internal/uv/runner_isolation_windows_test.go index 03d4415..328aa95 100644 --- a/internal/uv/runner_isolation_windows_test.go +++ b/internal/uv/runner_isolation_windows_test.go @@ -113,9 +113,14 @@ func TestRunner_AssignsBeforeExecuting(t *testing.T) { type cancelBeforeResumeJob struct { process.Job - cancel context.CancelFunc - pid uint32 - handle windows.Handle + cancel context.CancelFunc + pid uint32 + handle windows.Handle + closeError error +} + +func (j *cancelBeforeResumeJob) Close() error { + return errors.Join(j.Job.Close(), j.closeError) } func (j *cancelBeforeResumeJob) Assign(pid uint32) error { @@ -162,6 +167,28 @@ func TestRunner_CancelBeforeResumeDoesNotExecute(t *testing.T) { } } +func TestRunner_CancelBeforeResumePreservesCleanupError(t *testing.T) { + runner := newTestRunner(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + cleanupError := errors.New("job close failed") + job := &cancelBeforeResumeJob{cancel: cancel, closeError: cleanupError} + runner.newJob = func() (runnerJob, error) { + var err error + job.Job, err = process.NewJob() + return job, err + } + t.Cleanup(func() { + if job.handle != 0 { + _ = windows.CloseHandle(job.handle) + } + }) + _, err := runner.Run(ctx, []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{Stage: protocol.StageUVVerify}) + if !errors.Is(err, context.Canceled) || !errors.Is(err, cleanupError) { + t.Fatalf("Run() error = %v, want cancellation and job cleanup error", err) + } +} + func TestRunner_CancelReapsDescendants(t *testing.T) { runner := newTestRunner(t) ctx, cancel := context.WithCancel(t.Context()) From 369dffd44c5544e807b0cf2f80ef6e0e6ee028f9 Mon Sep 17 00:00:00 2001 From: Ji Mao Cai <44921326+jinghero@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:08:10 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=96=E6=B6=88?= =?UTF-8?q?=20uv=20=E5=90=AF=E5=8A=A8=E6=97=B6=E8=A2=AB=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=E4=B8=BA=E5=A4=B1=E8=B4=A5=E7=9A=84=E9=97=AE=E9=A2=98=20(T5.18?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...33\347\250\213\347\272\263\347\256\241.md" | 2 + ...33\347\250\213\347\272\263\347\256\241.md" | 12 +++ internal/uv/runner.go | 11 ++- internal/uv/runner_isolation_windows_test.go | 83 +++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) diff --git "a/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" "b/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" index 11b33eb..68b0a8a 100644 --- "a/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" +++ "b/doc/current/M5/\350\256\241\345\210\222-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" @@ -3,3 +3,5 @@ 1. 在 `internal/uv/runner_isolation_windows_test.go` 固定短进程已退出的原生 API 顺序,并从实际 `UVRunner.Run` 的 Job 边界复现错误。固定测试名为 `TestRunner_ExitedProcessCannotJoinJob`、`TestRunner_AssignsBeforeExecuting`。基线执行 `go test ./internal/uv -run 'TestRunner_(ExitedProcessCannotJoinJob|AssignsBeforeExecuting)$' -v -count=1`,前者通过,后者因子进程已退出、隔离失败而失败。 2. 在 `internal/uv/runner_windows.go` 挂起创建,`internal/uv/runner.go` 分配后恢复,`internal/process/managed_windows.go` 复用现有恢复线程实现;保留非 Windows 编译边界。补恢复前取消验证,复用现有执行器检查。 3. 运行 gofmt、vet、build、完整测试、protocol 100 轮和可用时的 race,复核真实 uv 探针。按实际结果更新设计;提交时显式暂存本任务源码、测试和文档,不改其他在途任务。代码与进度分别提交。提交后独立复核,不合入或推送其他分支。 + +4. PR #37 复审补充:固定 `Run` 通过取消检查后、恢复线程前发生取消或 deadline 的真实 Windows 顺序。只在恢复进程的 OS 调用处增加私有函数字段,默认调用既有 `process.ResumeSuspended`;不 mock `Run`、取消检查或错误分类。固定测试名 `TestRunner_ResumeFailurePreservesCancellation`,同时覆盖未取消的真实恢复失败,断言协议类别、真实进程退出及原始/清理错误链。先红后绿,复用 uv 全包和相关共享进程检查,不重跑已经明确受本机环境影响的全量与 race。 diff --git "a/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" index f4b79ed..e3b551a 100644 --- "a/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" +++ "b/doc/current/M5/\350\256\276\350\256\241-T5.18-\347\237\255\345\221\275uv\350\277\233\347\250\213\347\272\263\347\256\241.md" @@ -33,3 +33,15 @@ Job 创建、分配、线程恢复任一步失败均结束并等待本次进程 实际证据文件包括 `isolation-baseline-final.log`、`relevant-tests-final.log`、`isolation-repeat.log`、`descendant-cancel.log`、`uv0123-baseline.log`、`uv0123-fixed.log`、`full-tests.log`、`breakaway-baseline.log`、`subst-unmodified.log`、`race.log`。用户现场仍需使用发布后的 Runtime 收集具体失败 API 和 Windows 错误号,才能判断是否同一原因。 复审发现恢复前取消和恢复失败的新增分支丢弃了清理错误,现用既有错误链保留 Kill、Wait、Job.Close 和原始原因。`TestRunner_CancelBeforeResumePreservesCleanupError` 在修正前失败、修正后通过;真实 Job 纳管后取消,并在实际关闭后注入清理错误,确认取消与清理原因均可追溯。修正后的完整 uv 包、vet 和 build 再次通过。 + +## PR #37 恢复期间取消分类复审 + +[P2 审查](https://github.com/AUTO-MAS-Project/AUTO-MAS-Runtime/pull/37#discussion_r4549513344) 指出取消检查和线程恢复之间的窗口。Windows 原生复现确认:`Run` 已通过取消检查,随后用户取消或真实 context deadline 到达,`CommandContext` watcher 结束挂起进程;此时真实 `ResumeSuspended` 找不到初始线程。原实现仅保留恢复和清理错误,返回 `UV_EXEC_FAILED` / 50,没有保留 context 错误。 + +修正仅在恢复失败、完成本次 Kill/Wait/Job.Close 后重新检查 `ctx.Err()`,将取消与原始恢复和清理原因放入同一错误链,沿用已有 `OPERATION_CANCELLED` / 130 契约。未取消的真实恢复失败仍为隔离错误,不吞掉 API 错误,不改变协议、版本或重试策略。正常完成路径原本已经优先检查 `ctx.Err()`,本次使恢复失败路径遵守同一规则。 + +检查只在实际恢复进程的 OS 调用处提供与 `newJob` 同级的私有函数字段,默认仍调用 `process.ResumeSuspended`。`TestRunner_ResumeFailurePreservesCancellation` 执行真实生产 `Run`、Windows Job、`CommandContext` 和线程恢复;故障时序安排在通过取消检查后的恢复边界,不替换编排或分类。取消和真实 5 秒 deadline 两项等待实际进程句柄已收到退出信号后调用真实恢复,未取消对照则由外部终止挂起进程。三项均核对从未执行业务、进程已退出,以及原始恢复、真实 Wait 和实际 Job 关闭后的清理错误仍在链中。 + +验证基线为 `1ef8ac3ce0469fdb1c4c580d7052258d7a69cba8`。`go test ./internal/uv -run '^TestRunner_ResumeFailurePreservesCancellation$' -v -count=1` 修正前退出 1,取消和 deadline 错报 50,对照通过;修正后退出 0,分别为 130、130、50。`go test ./internal/uv -run '^TestRunner_ResumeFailurePreservesCancellation$/(cancel|uncancelled)$' -v -count=100` 退出 0。 + +`go test ./internal/uv -count=1`、共享进程挂起/分配/恢复失败/取消/空树/后代回收定向检查、CLI 取消错误链映射与最终 NDJSON 取消状态/退出码 130 检查均退出 0。`gofmt -l .` 无输出,`go vet ./...`、`go build -buildvcs=false ./...`、`git diff --check` 退出 0。本轮没有重跑已知受本机环境限制的完整测试或不可用的 race,不增加这些检查已通过的结论。原始命令输出位于本地 `review-followup-runtime` 证据目录。 diff --git a/internal/uv/runner.go b/internal/uv/runner.go index a3fe2bb..1a169ee 100644 --- a/internal/uv/runner.go +++ b/internal/uv/runner.go @@ -126,6 +126,7 @@ type UVRunner struct { CacheDir string Clock func() time.Time newJob func() (runnerJob, error) + resumeProcess func(*os.Process) error } // NewRunner 创建固定可执行文件的 uv 执行器。 @@ -305,13 +306,21 @@ func (r *UVRunner) Run( } return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, errors.Join(err, killErr, waitErr, closeErr)) } - if err := process.ResumeSuspended(command.Process); err != nil { + resumeProcess := r.resumeProcess + if resumeProcess == nil { + resumeProcess = process.ResumeSuspended + } + if err := resumeProcess(command.Process); err != nil { killErr := command.Process.Kill() waitErr := command.Wait() var closeErr error if job != nil { closeErr = job.Close() } + // CommandContext 可能在上次检查后结束挂起进程,线程恢复失败仍应保留取消语义。 + if cancelErr := ctx.Err(); cancelErr != nil { + return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, errors.Join(cancelErr, err, killErr, waitErr, closeErr)) + } failure := newError(protocol.CodeUVExecFailed, options.Stage, "uv 进程隔离失败", map[string]any{"failureKind": "process_isolation_failed"}, errors.Join(err, killErr, waitErr, closeErr)) failure.diagnostics = isolationDiagnostics(UVResult{ExitCode: exitCode(waitErr), Duration: clock().Sub(started)}, resolved, "resume_process", err) diff --git a/internal/uv/runner_isolation_windows_test.go b/internal/uv/runner_isolation_windows_test.go index 328aa95..427d390 100644 --- a/internal/uv/runner_isolation_windows_test.go +++ b/internal/uv/runner_isolation_windows_test.go @@ -189,6 +189,89 @@ func TestRunner_CancelBeforeResumePreservesCleanupError(t *testing.T) { } } +func TestRunner_ResumeFailurePreservesCancellation(t *testing.T) { + for _, test := range []struct { + name string + wantContext error + wantCode protocol.Code + wantExitCode protocol.ExitCode + }{ + {name: "cancel", wantContext: context.Canceled, wantCode: protocol.CodeOperationCancelled, wantExitCode: protocol.ExitCodeOperationCancelled}, + {name: "deadline", wantContext: context.DeadlineExceeded, wantCode: protocol.CodeOperationCancelled, wantExitCode: protocol.ExitCodeOperationCancelled}, + {name: "uncancelled", wantCode: protocol.CodeUVExecFailed, wantExitCode: protocol.ExitCodeEnvironmentFailure}, + } { + t.Run(test.name, func(t *testing.T) { + runner := newTestRunner(t) + ctx, cancel := context.WithCancel(t.Context()) + if test.name == "deadline" { + cancel() + ctx, cancel = context.WithTimeout(t.Context(), 5*time.Second) + } + defer cancel() + cleanupError := errors.New("job close failed after actual close") + job := &cancelBeforeResumeJob{cancel: func() {}, closeError: cleanupError} + runner.newJob = func() (runnerJob, error) { + var err error + job.Job, err = process.NewJob() + return job, err + } + t.Cleanup(func() { + if job.handle != 0 { + _ = windows.CloseHandle(job.handle) + } + }) + var resumeError error + var waitStatus uint32 + var waitError error + runner.resumeProcess = func(child *os.Process) error { + // 调用点已越过 Run 的取消检查,只控制外部进程恢复前的时序。 + switch test.name { + case "cancel": + cancel() + case "deadline": + <-ctx.Done() + case "uncancelled": + if err := child.Kill(); err != nil { + return err + } + } + waitStatus, waitError = windows.WaitForSingleObject(job.handle, 5000) + resumeError = process.ResumeSuspended(child) + return resumeError + } + recordPath := filepath.Join(t.TempDir(), "must-not-exist.txt") + _, err := runner.Run(ctx, []string{"-test.run=^TestFakeUVProcess$"}, RunOptions{ + Stage: protocol.StageUVVerify, Environment: map[string]string{"FAKE_UV_RECORD": recordPath}, + }) + var failure *Error + if !errors.As(err, &failure) { + t.Fatalf("Run() error = %v, want structured uv error", err) + } + definition, ok := protocol.LookupErrorDefinition(failure.Code()) + t.Logf("Run code=%s exitCode=%d ctx=%v resume=%v wait=%d", failure.Code(), definition.ExitCode, ctx.Err(), resumeError, waitStatus) + if failure.Code() != test.wantCode || !ok || definition.ExitCode != test.wantExitCode { + t.Errorf("Run() code=%s exitCode=%d, want %s / %d", failure.Code(), definition.ExitCode, test.wantCode, test.wantExitCode) + } + if !errors.Is(ctx.Err(), test.wantContext) || test.wantContext != nil && !errors.Is(err, test.wantContext) { + t.Errorf("Run() error = %v ctx=%v, want context %v in chain", err, ctx.Err(), test.wantContext) + } + if waitError != nil || waitStatus != windows.WAIT_OBJECT_0 || resumeError == nil { + t.Fatalf("resume boundary = wait %d error %v resume %v, want exited child and real resume failure", waitStatus, waitError, resumeError) + } + var exitError *exec.ExitError + if !errors.Is(err, resumeError) || !errors.Is(err, cleanupError) || !errors.As(err, &exitError) { + t.Errorf("Run() error = %v, want original resume, job cleanup and Wait errors", err) + } + if _, statErr := os.Stat(recordPath); !errors.Is(statErr, os.ErrNotExist) { + t.Errorf("suspended child executed: marker error = %v", statErr) + } + if test.wantContext == nil && (failure.Details()["failureKind"] != "process_isolation_failed" || failure.DiagnosticDetails()["operation"] != "resume_process") { + t.Errorf("uncancelled failure details = %v / %v, want isolation failure", failure.Details(), failure.DiagnosticDetails()) + } + }) + } +} + func TestRunner_CancelReapsDescendants(t *testing.T) { runner := newTestRunner(t) ctx, cancel := context.WithCancel(t.Context())