From 3b8e9a9cd2af567c8673d1e951d1d43ff31fd25c Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:57:23 +0100 Subject: [PATCH 1/2] test(thread): add cancellation integration test using fake codex binary - Build a tiny fake codex binary that writes its PID and waits for a signal - Verify RunStreamed cancellation returns context.Canceled - Assert the spawned process terminates on cancellation --- internal/codexexec/testdata/fakecodex/main.go | 33 +++++ thread_cancel_test.go | 123 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 internal/codexexec/testdata/fakecodex/main.go create mode 100644 thread_cancel_test.go diff --git a/internal/codexexec/testdata/fakecodex/main.go b/internal/codexexec/testdata/fakecodex/main.go new file mode 100644 index 0000000..b0c3532 --- /dev/null +++ b/internal/codexexec/testdata/fakecodex/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/signal" + "strconv" + "syscall" +) + +func main() { + pidFile := os.Getenv("CODEX_FAKE_PID_FILE") + if pidFile == "" { + fmt.Fprintln(os.Stderr, "CODEX_FAKE_PID_FILE not set") + os.Exit(2) + } + + // Drain stdin to avoid the parent process blocking while sending a prompt. + go io.Copy(io.Discard, os.Stdin) + + if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write pid file: %v\n", err) + os.Exit(3) + } + + // Block until a termination signal arrives. If the parent issues SIGKILL the + // process will exit immediately without delivering a signal on sigCh, which + // is fine for the integration test. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh +} diff --git a/thread_cancel_test.go b/thread_cancel_test.go new file mode 100644 index 0000000..ab92c22 --- /dev/null +++ b/thread_cancel_test.go @@ -0,0 +1,123 @@ +package godex + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/activadee/godex/internal/codexexec" +) + +func TestThreadRunStreamedCancellationTerminatesProcess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("cancellation integration test relies on unix signals") + } + + fakeBinary := buildFakeCodexBinary(t) + + runner, err := codexexec.New(fakeBinary) + if err != nil { + t.Fatalf("codexexec.New returned error: %v", err) + } + + pidFile := filepath.Join(t.TempDir(), "fake-codex.pid") + t.Setenv("CODEX_FAKE_PID_FILE", pidFile) + + thread := newThread(runner, CodexOptions{}, ThreadOptions{}, "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + result, err := thread.RunStreamed(ctx, "long running turn", nil) + if err != nil { + t.Fatalf("RunStreamed returned error: %v", err) + } + defer result.Close() + + eventsDrained := make(chan struct{}) + go func() { + for range result.Events() { + // drain events until stream closes + } + close(eventsDrained) + }() + defer func() { <-eventsDrained }() + + pid := waitForPIDFile(t, pidFile) + + cancel() + + if err := result.Wait(); !errors.Is(err, context.Canceled) { + t.Fatalf("result.Wait error = %v, want context.Canceled", err) + } + + waitForProcessExit(t, pid) +} + +func buildFakeCodexBinary(t *testing.T) string { + t.Helper() + + binDir := t.TempDir() + binaryPath := filepath.Join(binDir, "codex") + + cmd := exec.Command("go", "build", "-o", binaryPath, "./internal/codexexec/testdata/fakecodex") + cmd.Env = os.Environ() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build fake codex binary: %v\n%s", err, output) + } + + return binaryPath +} + +func waitForPIDFile(t *testing.T, pidFile string) int { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(pidFile) + if err == nil { + pidStr := strings.TrimSpace(string(data)) + pid, convErr := strconv.Atoi(pidStr) + if convErr != nil { + t.Fatalf("unexpected pid file contents %q: %v", pidStr, convErr) + } + if pid <= 0 { + t.Fatalf("invalid pid %d", pid) + } + return pid + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("reading pid file: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("timed out waiting for pid file %s", pidFile) + return 0 +} + +func waitForProcessExit(t *testing.T, pid int) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + err := syscall.Kill(pid, 0) + if errors.Is(err, syscall.ESRCH) { + return + } + if err != nil { + t.Fatalf("checking process %d status: %v", pid, err) + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("process %d still running after cancellation", pid) +} From b9098ed1aa2afe1692b9e94f549091a80e742273 Mon Sep 17 00:00:00 2001 From: activadee Date: Thu, 6 Nov 2025 20:57:44 +0100 Subject: [PATCH 2/2] fix(codexexec): propagate context cancellation from Runner.Run - Prefer ctx.Err when canceled over read/wait errors - Map scanner/Wait cancellations to context.Canceled/DeadlineExceeded - Ensure consistent termination semantics for spawned codex process --- internal/codexexec/runner.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/internal/codexexec/runner.go b/internal/codexexec/runner.go index 44b1b3e..174c893 100644 --- a/internal/codexexec/runner.go +++ b/internal/codexexec/runner.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "context" + "errors" "fmt" "io" "os" @@ -118,17 +119,36 @@ func (r *Runner) Run(ctx context.Context, args Args, handleLine func([]byte) err waitErr := cmd.Wait() stderrWG.Wait() + ctxErr := ctx.Err() + if readErr != nil { - return fmt.Errorf("reading codex output: %w", readErr) + switch { + case ctxErr != nil && errors.Is(readErr, ctxErr): + return ctxErr + case errors.Is(readErr, context.Canceled), errors.Is(readErr, context.DeadlineExceeded): + if ctxErr != nil { + return ctxErr + } + return readErr + default: + return fmt.Errorf("reading codex output: %w", readErr) + } } if waitErr != nil { + if ctxErr != nil { + return ctxErr + } if exitErr, ok := waitErr.(*exec.ExitError); ok { return fmt.Errorf("codex exec failed with code %d: %s", exitErr.ExitCode(), stderrBuf.String()) } return fmt.Errorf("codex exec failed: %w", waitErr) } + if ctxErr != nil { + return ctxErr + } + return nil }