mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* 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>
178 lines
5.8 KiB
Go
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
|
|
}
|