Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/current/M5/计划-T5.18-短命uv进程纳管.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# 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 探针。按实际结果更新设计;提交时显式暂存本任务源码、测试和文档,不改其他在途任务。代码与进度分别提交。提交后独立复核,不合入或推送其他分支。

4. PR #37 复审补充:固定 `Run` 通过取消检查后、恢复线程前发生取消或 deadline 的真实 Windows 顺序。只在恢复进程的 OS 调用处增加私有函数字段,默认调用既有 `process.ResumeSuspended`;不 mock `Run`、取消检查或错误分类。固定测试名 `TestRunner_ResumeFailurePreservesCancellation`,同时覆盖未取消的真实恢复失败,断言协议类别、真实进程退出及原始/清理错误链。先红后绿,复用 uv 全包和相关共享进程检查,不重跑已经明确受本机环境影响的全量与 race。
47 changes: 47 additions & 0 deletions doc/current/M5/设计-T5.18-短命uv进程纳管.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 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 错误号,才能判断是否同一原因。

复审发现恢复前取消和恢复失败的新增分支丢弃了清理错误,现用既有错误链保留 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` 证据目录。
11 changes: 11 additions & 0 deletions doc/任务拆分.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,13 @@ D6 的推论(重要):`workspace sync` 按内部模板 `release/<完整版

### - [x] M5 uv、Python 与依赖(internal/uv) ✅ 2026-10-22 `6fb4b81`

- [ ] **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)。
- 内容:uv 准备包装保留底层结构化细节;命令资源收尾后以同一命令和操作编号打开终态日志器,记录最终失败,避免写入已关闭的日志器。
Expand Down Expand Up @@ -1697,6 +1704,10 @@ T6.1~T6.7 固定测试名、Windows/race 门和双审清零规则完成;已

## 7. 变更记录

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 关联回执、状态环境和子类诊断;先修订设计、计划与架构,保持协议及错误优先级。
Expand Down
2 changes: 1 addition & 1 deletion doc/架构设计.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
8 changes: 7 additions & 1 deletion internal/process/managed_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
20 changes: 16 additions & 4 deletions internal/process/managed_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions internal/uv/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ type UVRunner struct {
CacheDir string
Clock func() time.Time
newJob func() (runnerJob, error)
resumeProcess func(*os.Process) error
}

// NewRunner 创建固定可执行文件的 uv 执行器。
Expand Down Expand Up @@ -294,6 +295,37 @@ func (r *UVRunner) Run(
return UVResult{}, failure
}
}
// 挂起创建阻止短命探针先于 Assign 退出,也阻止未纳管的后代逃出 Job。
// 取消先于恢复检查,避免退出期间再开始 uv 的业务工作。
if err := ctx.Err(); err != nil {
killErr := command.Process.Kill()
waitErr := command.Wait()
var closeErr error
if job != nil {
closeErr = job.Close()
}
return UVResult{}, runnerCancellationError(options.Stage, map[string]any{}, errors.Join(err, killErr, waitErr, closeErr))
}
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))
Comment thread
jinghero marked this conversation as resolved.
Resolved
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 {
Expand Down
Loading