Files
multica/server/pkg/agent/proc_windows_test.go
milymarkovic 09dce598df MUL-5155: KAP-1051: diagnose Codex thread/start timeouts fail-closed (#5759)
* fix(agent/codex): diagnose thread start timeouts (KAP-1051)

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent/codex): validate thread ID before success lifecycle

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent/codex): confirm process tree cleanup

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent/codex): bound Windows pipe cleanup

Co-authored-by: multica-agent <github@multica.ai>

* ci: run bounded Codex cleanup test on Windows

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): reap initialize timeout process groups

* fix(agent): redact initialize timeout stderr

* fix(agent): redact initialize context failures

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 15:09:50 +08:00

178 lines
5.8 KiB
Go

//go:build windows
package agent
import (
"bytes"
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// TestHideAgentWindowSetsCreateNewConsole guards against a regression where
// hideAgentWindow reverts to CREATE_NO_WINDOW. CREATE_NO_WINDOW strips the
// console entirely, which forces Windows to allocate a new visible console
// per grandchild that doesn't itself pass CREATE_NO_WINDOW — the popup
// storm reported in #1521.
func TestHideAgentWindowSetsCreateNewConsole(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "echo", "hi")
hideAgentWindow(cmd)
if cmd.SysProcAttr == nil {
t.Fatal("SysProcAttr should be initialized")
}
if !cmd.SysProcAttr.HideWindow {
t.Error("HideWindow should be true")
}
if cmd.SysProcAttr.CreationFlags&createNewConsole == 0 {
t.Errorf("CreationFlags should include CREATE_NEW_CONSOLE (0x%x), got 0x%x",
createNewConsole, cmd.SysProcAttr.CreationFlags)
}
const createNoWindow = 0x08000000
if cmd.SysProcAttr.CreationFlags&createNoWindow != 0 {
t.Errorf("CreationFlags must NOT include CREATE_NO_WINDOW (0x%x), got 0x%x — "+
"see #1521 for why this causes grandchild popups",
createNoWindow, cmd.SysProcAttr.CreationFlags)
}
}
// TestHideAgentWindowPreservesExistingSysProcAttr ensures hideAgentWindow
// does not overwrite fields set by callers — a regression caught in PR #1474
// where the whole SysProcAttr struct was replaced. We verify both a
// non-CreationFlags field and a pre-existing CreationFlags bit survive.
//
// CREATE_UNICODE_ENVIRONMENT (0x00000400) is chosen because it is documented
// as compatible with CREATE_NEW_CONSOLE (unlike CREATE_NEW_PROCESS_GROUP,
// which Windows silently ignores when combined with CREATE_NEW_CONSOLE), so
// a surviving bit here is semantically meaningful, not just bitwise intact.
func TestHideAgentWindowPreservesExistingSysProcAttr(t *testing.T) {
const createUnicodeEnvironment = 0x00000400
cmd := exec.Command("cmd.exe", "/c", "echo", "hi")
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: createUnicodeEnvironment,
NoInheritHandles: true,
}
hideAgentWindow(cmd)
if !cmd.SysProcAttr.NoInheritHandles {
t.Error("NoInheritHandles set by caller should be preserved")
}
if cmd.SysProcAttr.CreationFlags&createUnicodeEnvironment == 0 {
t.Error("existing CreationFlags bits (CREATE_UNICODE_ENVIRONMENT) should be preserved")
}
if cmd.SysProcAttr.CreationFlags&createNewConsole == 0 {
t.Error("CREATE_NEW_CONSOLE should be OR'd into existing flags")
}
}
func TestCodexInitializeRetrySuppressedWithoutConfirmedTreeCleanup(t *testing.T) {
if codexInitializeRetrySupported() {
t.Fatal("Codex initialize retry must remain disabled until Windows descendant cleanup is positively confirmed")
}
}
func TestCodexWindowsInheritedStdoutDescendantCleanupIsBounded(t *testing.T) {
tempDir := t.TempDir()
sourcePath := filepath.Join(tempDir, "fake_codex.go")
exePath := filepath.Join(tempDir, "fake_codex.exe")
pidPath := filepath.Join(tempDir, "descendant.pid")
const source = `package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"time"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "--version" { fmt.Println("codex-cli windows-test"); return }
if len(os.Args) > 1 && os.Args[1] == "descendant" { time.Sleep(30*time.Second); return }
s := bufio.NewScanner(os.Stdin)
if !s.Scan() { return }
fmt.Println("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}")
if !s.Scan() { return }
if !s.Scan() { return }
child := exec.Command(os.Args[0], "descendant")
child.Stdout = os.Stdout
child.Stderr = os.Stderr
if err := child.Start(); err != nil { panic(err) }
_ = os.WriteFile(os.Getenv("DESCENDANT_PID_FILE"), []byte(fmt.Sprint(child.Process.Pid)), 0600)
time.Sleep(time.Hour)
}`
if err := os.WriteFile(sourcePath, []byte(source), 0o600); err != nil {
t.Fatal(err)
}
build := exec.Command("go", "build", "-o", exePath, sourcePath)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build Windows fake app-server: %v: %s", err, output)
}
codexGracefulShutdownTimeoutNanos.Store(int64(100 * time.Millisecond))
codexProcessWaitDelayNanos.Store(int64(200 * time.Millisecond))
t.Cleanup(func() {
codexGracefulShutdownTimeoutNanos.Store(0)
codexProcessWaitDelayNanos.Store(0)
if raw, err := os.ReadFile(pidPath); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil {
if process, err := os.FindProcess(pid); err == nil {
_ = process.Kill()
}
}
}
})
var logs bytes.Buffer
backend, err := New("codex", Config{
ExecutablePath: exePath,
Logger: slog.New(slog.NewJSONHandler(&logs, nil)),
Env: map[string]string{"DESCENDANT_PID_FILE": pidPath},
})
if err != nil {
t.Fatal(err)
}
started := time.Now()
session, err := backend.Execute(context.Background(), "prompt", ExecOptions{
Timeout: 5 * time.Second,
HandshakeTimeout: 500 * time.Millisecond,
})
if err != nil {
t.Fatal(err)
}
go func() {
for range session.Messages {
}
}()
result := <-session.Result
if elapsed := time.Since(started); elapsed > 3*time.Second {
t.Fatalf("cleanup exceeded bound: %s", elapsed)
}
if result.Status != "failed" || !strings.Contains(result.Error, "thread/start") {
t.Fatalf("expected thread/start failure, got %+v", result)
}
entries := parseJSONLogEntries(t, logs.String())
failure := findCodexLifecyclePhase(t, entries, "thread_start_failure")
if failure["cleanup_confirmed"] != false || failure["reaped"] != false {
t.Fatalf("Windows tree cleanup must remain unconfirmed: %v", failure)
}
if phaseCount(entries, "thread_start_response") != 0 {
t.Fatalf("unexpected thread_start_response: %v", entries)
}
}
func phaseCount(entries []map[string]any, phase string) int {
count := 0
for _, entry := range entries {
if entry["phase"] == phase {
count++
}
}
return count
}