fix: 修复 Windows Codex 进程完成后不自动退出问题 (v1.7.21)

问题根因:
1. messageTimerCh 中阻塞等待 waitCh 导致无限挂起
2. Windows 上 proc.Kill() 只杀主进程不杀子进程

解决方案:
1. 移除 messageTimerCh case 中的阻塞等待
2. 新增 killProcessTree() 使用 taskkill /T /F /PID 终止进程树
3. 修改 terminateCommand/terminateProcess/forwardSignals 使用进程树终止
This commit is contained in:
li committed 2026-01-13 16:32:28 +08:00
1 parent e5cfdee7a9
commit 1237d2bfe0
5 files changed
+123 -15

No files matched your search

+69
View File
@@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.7.21] - 2026-01-13
### 🐛 Bug 修复
**彻底修复 Windows 系统 Codex 进程无法自动终止问题**
#### 问题背景
v1.7.18 的修复不完整,Windows 上 Codex 完成后进程仍然卡住:
- Web UI 显示完成,但 codeagent-wrapper 进程一直不退出
- 只有手动杀死进程才能继续
- Mac 没问题,Windows 的 Gemini 也没问题
#### 根本原因分析
1. **进程树问题**:`proc.Kill()` 只终止主进程,Codex 启动的子进程(Node.js workers)仍然运行,持有 stdout handle
2. **阻塞等待问题**:`messageTimerCh` case 里直接 `waitErr = <-waitCh` 阻塞等待,如果进程没被杀死就永远卡住
#### 核心修复
1. **使用 `taskkill /T` 终止整个进程树**:
- 新增 `killProcessTree()` 函数,使用 `taskkill /T /F /PID` 递归杀死所有子进程
- `terminateCommand()`、`terminateProcess()`、`forwardSignals()` 在 Windows 上调用进程树终止
2. **移除阻塞等待**:
- `messageTimerCh` case 里不再直接阻塞等待 `waitCh`
- 让循环继续,下一轮通过 `case waitErr = <-waitCh` 正常退出
- 即使 `taskkill` 失败,也不会永远卡住
#### 技术细节
修改文件:`codeagent-wrapper/executor.go`
```go
// 新增:Windows 进程树终止
func killProcessTree(pid int) error {
if !isWindows() {
return nil
}
cmd := exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", pid))
return cmd.Run()
}
// 修改:terminateCommand 使用进程树终止
if isWindows() {
if err := killProcessTree(proc.Pid()); err != nil {
_ = proc.Kill() // fallback
}
}
// 修改:messageTimerCh case 不再阻塞
case <-messageTimerCh:
// ...terminate logic...
// Do NOT block here - let loop continue
```
#### 影响范围
- ✅ **修复前**:Windows 用户 Codex 完成后进程卡住
- ✅ **修复后**:所有平台进程正确终止
- ✅ **向后兼容**:Unix 平台行为不变
#### codeagent-wrapper 版本
- 升级至 v5.6.0
- 重新编译所有平台二进制文件
---
## [1.7.20] - 2026-01-13
### 🐛 Bug 修复
+49 -10
View File
@@ -1126,8 +1126,9 @@ waitLoop:
terminated = true
}
}
waitErr = <-waitCh
break waitLoop
// Do NOT block here waiting for waitCh - let the loop continue
// This is critical for Windows where process termination may fail silently
// The next iteration will catch waitCh if the process exits
case <-completeSeen:
completeSeenObserved = true
if messageTimer != nil {
@@ -1256,9 +1257,11 @@ func forwardSignals(ctx context.Context, cmd commandRunner, logErrorFn func(stri
case sig := <-sigCh:
logErrorFn(fmt.Sprintf("Received signal: %v", sig))
if proc := cmd.Process(); proc != nil {
// Windows does not support SIGTERM - use Kill() directly
// Windows does not support SIGTERM - use taskkill /T to kill process tree
if isWindows() {
_ = proc.Kill()
if err := killProcessTree(proc.Pid()); err != nil {
_ = proc.Kill()
}
} else {
_ = proc.Signal(syscall.SIGTERM)
time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
@@ -1322,6 +1325,26 @@ func (t *forceKillTimer) Stop() {
t.stopped.Store(true)
}
// killProcessTree terminates a process and all its child processes on Windows.
// Uses taskkill /T /F /PID which recursively kills the entire process tree.
// This is necessary because Codex CLI may spawn child processes (Node.js workers, etc.)
// that inherit stdout handles and prevent the parent from exiting cleanly.
// Returns nil on non-Windows platforms or if the process has already exited.
func killProcessTree(pid int) error {
if !isWindows() {
return nil
}
if pid <= 0 {
return nil
}
// taskkill /T = kill process tree, /F = force, /PID = process ID
cmd := exec.Command("taskkill", "/T", "/F", "/PID", fmt.Sprintf("%d", pid))
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run()
}
func terminateCommand(cmd commandRunner) *forceKillTimer {
if cmd == nil {
return nil
@@ -1333,9 +1356,14 @@ func terminateCommand(cmd commandRunner) *forceKillTimer {
// Windows does not support SIGTERM - it silently fails without terminating the process.
// This causes codeagent-wrapper to hang waiting for the process to exit.
// On Windows, we directly use Kill() which calls TerminateProcess().
// On Windows, we use taskkill /T to kill the entire process tree, not just the main process.
// This is critical because Codex CLI spawns child processes that hold stdout handles open.
if isWindows() {
_ = proc.Kill()
pid := proc.Pid()
if err := killProcessTree(pid); err != nil {
// Fallback to direct Kill() if taskkill fails
_ = proc.Kill()
}
} else {
_ = proc.Signal(syscall.SIGTERM)
}
@@ -1343,7 +1371,11 @@ func terminateCommand(cmd commandRunner) *forceKillTimer {
done := make(chan struct{}, 1)
timer := time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
if isWindows() {
_ = killProcessTree(p.Pid())
} else {
_ = p.Kill()
}
}
close(done)
})
@@ -1360,16 +1392,23 @@ func terminateProcess(cmd commandRunner) *time.Timer {
return nil
}
// Windows does not support SIGTERM - use Kill() directly
// Windows does not support SIGTERM - use taskkill /T to kill process tree
if isWindows() {
_ = proc.Kill()
pid := proc.Pid()
if err := killProcessTree(pid); err != nil {
_ = proc.Kill()
}
} else {
_ = proc.Signal(syscall.SIGTERM)
}
return time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
if isWindows() {
_ = killProcessTree(p.Pid())
} else {
_ = p.Kill()
}
}
})
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
const (
version = "5.5.0"
version = "5.6.0"
defaultWorkdir = "."
defaultTimeout = 7200 // seconds (2 hours)
defaultCoverageTarget = 90.0
+3 -3
View File
@@ -3067,7 +3067,7 @@ func TestVersionFlag(t *testing.T) {
}
})
want := "codeagent-wrapper version 5.4.0\n"
want := "codeagent-wrapper version 5.6.0\n"
if output != want {
t.Fatalf("output = %q, want %q", output, want)
@@ -3083,7 +3083,7 @@ func TestVersionShortFlag(t *testing.T) {
}
})
want := "codeagent-wrapper version 5.4.0\n"
want := "codeagent-wrapper version 5.6.0\n"
if output != want {
t.Fatalf("output = %q, want %q", output, want)
@@ -3099,7 +3099,7 @@ func TestVersionLegacyAlias(t *testing.T) {
}
})
want := "codex-wrapper version 5.4.0\n"
want := "codex-wrapper version 5.6.0\n"
if output != want {
t.Fatalf("output = %q, want %q", output, want)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ccg-workflow",
"version": "1.7.20",
"version": "1.7.21",
"description": "Claude-Codex-Gemini 多模型协作系统 - 智能路由多模型开发工作流",
"type": "module",
"packageManager": "pnpm@10.17.1",