Files
myclaude/codeagent-wrapper/internal/app/executor_concurrent_test.go
cexll 2c0553794a fix: Windows compatibility and flaky benchmark test
- Use cmd.exe /c to execute .bat/.cmd on Windows
- Set USERPROFILE alongside HOME for os.UserHomeDir()
- Use setTempDirEnv to set TEMP/TMP on Windows
- Replace chmod-based tests with cross-platform alternatives
- Fix concurrent speedup benchmark with fair comparison
- Add output/ to gitignore

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-26 21:29:54 +08:00

1133 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package wrapper
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
executor "codeagent-wrapper/internal/executor"
)
var executorTestTaskCounter atomic.Int64
func nextExecutorTestTaskID(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, executorTestTaskCounter.Add(1))
}
type execFakeProcess struct {
pid int
signals []os.Signal
killed atomic.Int32
mu sync.Mutex
}
func (p *execFakeProcess) Pid() int {
if runtime.GOOS == "windows" {
return 0
}
return p.pid
}
func (p *execFakeProcess) Kill() error {
p.killed.Add(1)
return nil
}
func (p *execFakeProcess) Signal(sig os.Signal) error {
p.mu.Lock()
p.signals = append(p.signals, sig)
p.mu.Unlock()
return nil
}
type writeCloserStub struct {
bytes.Buffer
closed atomic.Bool
}
func (w *writeCloserStub) Close() error {
w.closed.Store(true)
return nil
}
type reasonReadCloser struct {
r io.Reader
closed []string
mu sync.Mutex
closedC chan struct{}
}
func newReasonReadCloser(data string) *reasonReadCloser {
return &reasonReadCloser{r: strings.NewReader(data), closedC: make(chan struct{}, 1)}
}
func (rc *reasonReadCloser) Read(p []byte) (int, error) { return rc.r.Read(p) }
func (rc *reasonReadCloser) Close() error { rc.record("close"); return nil }
func (rc *reasonReadCloser) CloseWithReason(reason string) error {
rc.record(reason)
return nil
}
func (rc *reasonReadCloser) record(reason string) {
rc.mu.Lock()
rc.closed = append(rc.closed, reason)
rc.mu.Unlock()
select {
case rc.closedC <- struct{}{}:
default:
}
}
type execFakeRunner struct {
stdout io.ReadCloser
stderr io.ReadCloser
process executor.ProcessHandle
stdin io.WriteCloser
dir string
env map[string]string
waitErr error
waitDelay time.Duration
startErr error
stdoutErr error
stderrErr error
stdinErr error
allowNilProcess bool
started atomic.Bool
}
func (f *execFakeRunner) Start() error {
if f.startErr != nil {
return f.startErr
}
f.started.Store(true)
return nil
}
func (f *execFakeRunner) Wait() error {
if f.waitDelay > 0 {
time.Sleep(f.waitDelay)
}
return f.waitErr
}
func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
if f.stdoutErr != nil {
return nil, f.stdoutErr
}
if f.stdout == nil {
f.stdout = io.NopCloser(strings.NewReader(""))
}
return f.stdout, nil
}
func (f *execFakeRunner) StderrPipe() (io.ReadCloser, error) {
if f.stderrErr != nil {
return nil, f.stderrErr
}
if f.stderr == nil {
f.stderr = io.NopCloser(strings.NewReader(""))
}
return f.stderr, nil
}
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
if f.stdinErr != nil {
return nil, f.stdinErr
}
if f.stdin != nil {
return f.stdin, nil
}
return &writeCloserStub{}, nil
}
func (f *execFakeRunner) SetStderr(io.Writer) {}
func (f *execFakeRunner) SetDir(dir string) { f.dir = dir }
func (f *execFakeRunner) SetEnv(env map[string]string) {
if len(env) == 0 {
return
}
if f.env == nil {
f.env = make(map[string]string, len(env))
}
for k, v := range env {
f.env[k] = v
}
}
func (f *execFakeRunner) Process() executor.ProcessHandle {
if f.process != nil {
return f.process
}
if f.allowNilProcess {
return nil
}
return &execFakeProcess{pid: 1}
}
func TestExecutorRunCodexTaskWithContext(t *testing.T) {
defer resetTestHooks()
t.Run("resumeMissingSessionID", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
t.Fatalf("unexpected command execution for invalid resume config")
return nil
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", Mode: "resume"}, nil, nil, false, false, 1)
if res.ExitCode == 0 || !strings.Contains(res.Error, "session_id") {
t.Fatalf("expected validation error, got %+v", res)
}
})
t.Run("success", func(t *testing.T) {
var firstStdout *reasonReadCloser
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`)
if firstStdout == nil {
firstStdout = rc
}
return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-1", Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.Error != "" || res.Message != "hello" || res.ExitCode != 0 {
t.Fatalf("unexpected result: %+v", res)
}
select {
case <-firstStdout.closedC:
case <-time.After(1 * time.Second):
t.Fatalf("stdout not closed with reason")
}
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
}
t.Cleanup(func() { runCodexTaskFn = orig })
if res := runCodexTask(TaskSpec{Task: "task-text", WorkDir: "."}, true, 1); res.ExitCode != 0 {
t.Fatalf("runCodexTask failed: %+v", res)
}
msg, threadID, code := runCodexProcess(context.Background(), []string{"arg"}, "content", false, 1)
if code != 0 || msg == "" {
t.Fatalf("runCodexProcess unexpected result: msg=%q code=%d threadID=%s", msg, code, threadID)
}
})
t.Run("startErrors", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode != 127 {
t.Fatalf("expected missing executable exit code, got %d", res.ExitCode)
}
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}}
})
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on start failure")
}
})
t.Run("timeoutAndPipes", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`),
process: &execFakeProcess{pid: 5},
waitDelay: 20 * time.Millisecond,
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0)
if res.ExitCode == 0 {
t.Fatalf("expected timeout result, got %+v", res)
}
})
t.Run("pipeErrors", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdout pipe error")
}
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{stdinErr: errors.New("stdin fail"), process: &execFakeProcess{pid: 7}}
})
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdin pipe error")
}
})
t.Run("waitExitError", func(t *testing.T) {
err := exec.Command("false").Run()
exitErr, _ := err.(*exec.ExitError)
if exitErr == nil {
t.Fatalf("expected exec.ExitError")
}
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`),
process: &execFakeProcess{pid: 8},
waitErr: exitErr,
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on wait error")
}
})
t.Run("contextCancelled", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`),
process: &execFakeProcess{pid: 9},
waitDelay: 10 * time.Millisecond,
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected cancellation result")
}
})
t.Run("silentLogger", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`),
process: &execFakeProcess{pid: 10},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath == "" {
t.Fatalf("expected success with temp logger, got %+v", res)
}
_ = closeLogger()
})
t.Run("injectedLogger", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"injected"}}`),
process: &execFakeProcess{pid: 12},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
injected, err := NewLoggerWithSuffix("executor-injected")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer func() {
_ = injected.Close()
_ = os.Remove(injected.Path())
}()
ctx := executor.WithTaskLogger(context.Background(), injected)
res := runCodexTaskWithContext(ctx, TaskSpec{ID: "task-injected", Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != injected.Path() {
t.Fatalf("expected injected logger path, got %+v", res)
}
if activeLogger() != nil {
t.Fatalf("expected no global logger to be created when injected")
}
injected.Flush()
data, err := os.ReadFile(injected.Path())
if err != nil {
t.Fatalf("failed to read injected log file: %v", err)
}
if !strings.Contains(string(data), "task-injected") {
t.Fatalf("injected log missing task prefix, content: %s", string(data))
}
})
t.Run("contextLoggerWithoutParent", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ctx"}}`),
process: &execFakeProcess{pid: 14},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
taskLogger, err := NewLoggerWithSuffix("executor-taskctx")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
t.Cleanup(func() {
_ = taskLogger.Close()
_ = os.Remove(taskLogger.Path())
})
ctx := executor.WithTaskLogger(context.Background(), taskLogger)
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-context", Task: "payload", WorkDir: ".", Context: ctx}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath != taskLogger.Path() {
t.Fatalf("expected task logger to be reused from spec context, got %+v", res)
}
if activeLogger() != nil {
t.Fatalf("expected no global logger to be created when task context provides one")
}
taskLogger.Flush()
data, err := os.ReadFile(taskLogger.Path())
if err != nil {
t.Fatalf("failed to read task log: %v", err)
}
if !strings.Contains(string(data), "task-context") {
t.Fatalf("task log missing task id, content: %s", string(data))
}
})
t.Run("backendSetsDirAndNilContext", func(t *testing.T) {
var rc *execFakeRunner
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
rc = &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"backend"}}`),
process: &execFakeProcess{pid: 13},
}
return rc
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
res := runCodexTaskWithContext(context.TODO(), TaskSpec{ID: "task-backend", Task: "payload", WorkDir: "/tmp"}, ClaudeBackend{}, nil, false, false, 1)
if res.ExitCode != 0 || res.Message != "backend" {
t.Fatalf("unexpected result: %+v", res)
}
if rc == nil || rc.dir != "/tmp" {
t.Fatalf("expected backend to set cmd.Dir, got runner=%v dir=%q", rc, rc.dir)
}
})
t.Run("claudeSkipPermissionsPropagatesFromTaskSpec", func(t *testing.T) {
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
var gotArgs []string
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
gotArgs = append([]string(nil), args...)
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`),
process: &execFakeProcess{pid: 15},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
_ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-skip", Task: "payload", WorkDir: ".", SkipPermissions: true}, ClaudeBackend{}, nil, false, false, 1)
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected result: %+v", res)
}
if !slices.Contains(gotArgs, "--dangerously-skip-permissions") {
t.Fatalf("expected --dangerously-skip-permissions in args, got %v", gotArgs)
}
})
t.Run("missingMessage", func(t *testing.T) {
executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`),
process: &execFakeProcess{pid: 11},
}
})
t.Cleanup(func() { executor.SetNewCommandRunner(nil) })
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure when no agent_message returned")
}
})
}
func TestExecutorParallelLogIsolation(t *testing.T) {
mainLogger, err := NewLoggerWithSuffix("executor-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
taskA := nextExecutorTestTaskID("iso-a")
taskB := nextExecutorTestTaskID("iso-b")
markerA := "ISOLATION_MARKER:" + taskA
markerB := "ISOLATION_MARKER:" + taskB
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context)
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
}
switch task.ID {
case taskA:
logger.Info(markerA)
case taskB:
logger.Info(markerB)
default:
logger.Info("unexpected task: " + task.ID)
}
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = origRun })
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrW
defer func() { os.Stderr = oldStderr }()
results := executeConcurrentWithContext(context.TODO(), [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, -1)
_ = stderrW.Close()
os.Stderr = oldStderr
stderrData, _ := io.ReadAll(stderrR)
_ = stderrR.Close()
stderrOut := string(stderrData)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
paths := map[string]string{}
for _, res := range results {
if res.ExitCode != 0 {
t.Fatalf("unexpected failure: %+v", res)
}
if res.LogPath == "" {
t.Fatalf("missing LogPath for task %q", res.TaskID)
}
paths[res.TaskID] = res.LogPath
}
if paths[taskA] == paths[taskB] {
t.Fatalf("expected distinct task log paths, got %q", paths[taskA])
}
if strings.Contains(stderrOut, mainLogger.Path()) {
t.Fatalf("stderr should not print main log path: %s", stderrOut)
}
if !strings.Contains(stderrOut, paths[taskA]) || !strings.Contains(stderrOut, paths[taskB]) {
t.Fatalf("stderr should include task log paths, got: %s", stderrOut)
}
mainLogger.Flush()
mainData, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
if strings.Contains(string(mainData), markerA) || strings.Contains(string(mainData), markerB) {
t.Fatalf("main log should not contain task markers, content: %s", string(mainData))
}
taskAData, err := os.ReadFile(paths[taskA])
if err != nil {
t.Fatalf("failed to read task A log: %v", err)
}
taskBData, err := os.ReadFile(paths[taskB])
if err != nil {
t.Fatalf("failed to read task B log: %v", err)
}
if !strings.Contains(string(taskAData), markerA) || strings.Contains(string(taskAData), markerB) {
t.Fatalf("task A log isolation failed, content: %s", string(taskAData))
}
if !strings.Contains(string(taskBData), markerB) || strings.Contains(string(taskBData), markerA) {
t.Fatalf("task B log isolation failed, content: %s", string(taskBData))
}
_ = os.Remove(paths[taskA])
_ = os.Remove(paths[taskB])
}
func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
setTempDirEnv(t, t.TempDir())
oldArgs := os.Args
os.Args = []string{wrapperName}
t.Cleanup(func() { os.Args = oldArgs })
mainLogger, err := NewLoggerWithSuffix("concurrent-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
mainLogger.Flush()
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
const taskCount = 16
const writersPerTask = 4
const logsPerWriter = 50
const expectedTaskLines = writersPerTask * logsPerWriter
taskIDs := make([]string, 0, taskCount)
tasks := make([]TaskSpec, 0, taskCount)
for i := 0; i < taskCount; i++ {
id := nextExecutorTestTaskID("iso")
taskIDs = append(taskIDs, id)
tasks = append(tasks, TaskSpec{ID: id})
}
type taskLoggerInfo struct {
taskID string
logger *Logger
}
loggerCh := make(chan taskLoggerInfo, taskCount)
readyCh := make(chan struct{}, taskCount)
startCh := make(chan struct{})
go func() {
for i := 0; i < taskCount; i++ {
<-readyCh
}
close(startCh)
}()
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
readyCh <- struct{}{}
logger := executor.TaskLoggerFromContext(task.Context)
loggerCh <- taskLoggerInfo{taskID: task.ID, logger: logger}
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing task logger"}
}
<-startCh
var wg sync.WaitGroup
wg.Add(writersPerTask)
for g := 0; g < writersPerTask; g++ {
go func(g int) {
defer wg.Done()
for i := 0; i < logsPerWriter; i++ {
logger.Info(fmt.Sprintf("TASK=%s g=%d i=%d", task.ID, g, i))
}
}(g)
}
wg.Wait()
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = origRun })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{tasks}, 1, 0)
if len(results) != taskCount {
t.Fatalf("expected %d results, got %d", taskCount, len(results))
}
taskLogPaths := make(map[string]string, taskCount)
seenPaths := make(map[string]struct{}, taskCount)
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected task failure: %+v", res)
}
if res.LogPath == "" {
t.Fatalf("missing LogPath for task %q", res.TaskID)
}
if _, ok := taskLogPaths[res.TaskID]; ok {
t.Fatalf("duplicate TaskID in results: %q", res.TaskID)
}
taskLogPaths[res.TaskID] = res.LogPath
if _, ok := seenPaths[res.LogPath]; ok {
t.Fatalf("expected unique log path per task; duplicate path %q", res.LogPath)
}
seenPaths[res.LogPath] = struct{}{}
}
if len(taskLogPaths) != taskCount {
t.Fatalf("expected %d unique task IDs, got %d", taskCount, len(taskLogPaths))
}
prefix := primaryLogPrefix()
pid := os.Getpid()
for _, id := range taskIDs {
path := taskLogPaths[id]
if path == "" {
t.Fatalf("missing log path for task %q", id)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("task log file not created for %q: %v", id, err)
}
wantBase := fmt.Sprintf("%s-%d-%s.log", prefix, pid, id)
if got := filepath.Base(path); got != wantBase {
t.Fatalf("unexpected log filename for %q: got %q, want %q", id, got, wantBase)
}
}
loggers := make(map[string]*Logger, taskCount)
for i := 0; i < taskCount; i++ {
info := <-loggerCh
if info.taskID == "" {
t.Fatalf("missing taskID in logger info")
}
if info.logger == nil {
t.Fatalf("missing logger in context for task %q", info.taskID)
}
if prev, ok := loggers[info.taskID]; ok && prev != info.logger {
t.Fatalf("task %q received multiple logger instances", info.taskID)
}
loggers[info.taskID] = info.logger
}
if len(loggers) != taskCount {
t.Fatalf("expected %d task loggers, got %d", taskCount, len(loggers))
}
for taskID, logger := range loggers {
if !logger.IsClosed() {
t.Fatalf("expected task logger to be closed for %q", taskID)
}
}
mainLogger.Flush()
mainData, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
mainText := string(mainData)
if !strings.Contains(mainText, "parallel: worker_limit=") {
t.Fatalf("expected main log to include concurrency planning, content: %s", mainText)
}
if strings.Contains(mainText, "TASK=") {
t.Fatalf("main log should not contain task output, content: %s", mainText)
}
for taskID, path := range taskLogPaths {
f, err := os.Open(path)
if err != nil {
t.Fatalf("failed to open task log for %q: %v", taskID, err)
}
scanner := bufio.NewScanner(f)
lines := 0
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "parallel:") {
t.Fatalf("task log should not contain main log entries for %q: %s", taskID, line)
}
gotID, ok := parseTaskIDFromLogLine(line)
if !ok {
t.Fatalf("task log entry missing task marker for %q: %s", taskID, line)
}
if gotID != taskID {
t.Fatalf("task log isolation failed: file=%q got TASK=%q want TASK=%q", path, gotID, taskID)
}
lines++
}
if err := scanner.Err(); err != nil {
_ = f.Close()
t.Fatalf("scanner error for %q: %v", taskID, err)
}
if err := f.Close(); err != nil {
t.Fatalf("failed to close task log for %q: %v", taskID, err)
}
if lines != expectedTaskLines {
t.Fatalf("unexpected task log line count for %q: got %d, want %d", taskID, lines, expectedTaskLines)
}
}
for _, path := range taskLogPaths {
_ = os.Remove(path)
}
}
func parseTaskIDFromLogLine(line string) (string, bool) {
const marker = "TASK="
idx := strings.Index(line, marker)
if idx == -1 {
return "", false
}
rest := line[idx+len(marker):]
end := strings.IndexByte(rest, ' ')
if end == -1 {
return rest, rest != ""
}
return rest[:end], rest[:end] != ""
}
func TestExecutorTaskLoggerContext(t *testing.T) {
if executor.TaskLoggerFromContext(context.TODO()) != nil {
t.Fatalf("expected nil logger from TODO context")
}
if executor.TaskLoggerFromContext(context.Background()) != nil {
t.Fatalf("expected nil logger when context has no logger")
}
logger, err := NewLoggerWithSuffix("executor-taskctx")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
defer func() {
_ = logger.Close()
_ = os.Remove(logger.Path())
}()
ctx := executor.WithTaskLogger(context.Background(), logger)
if got := executor.TaskLoggerFromContext(ctx); got != logger {
t.Fatalf("expected logger roundtrip, got %v", got)
}
if executor.TaskLoggerFromContext(executor.WithTaskLogger(context.Background(), nil)) != nil {
t.Fatalf("expected nil logger when injected logger is nil")
}
}
func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("failed to open %s: %v", os.DevNull, err)
}
oldStderr := os.Stderr
os.Stderr = devNull
t.Cleanup(func() {
os.Stderr = oldStderr
_ = devNull.Close()
})
t.Run("skipOnFailedDependencies", func(t *testing.T) {
root := nextExecutorTestTaskID("root")
child := nextExecutorTestTaskID("child")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
if task.ID == root {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "boom"}
}
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{
{{ID: root}},
{{ID: child, Dependencies: []string{root}}},
}, 1, 0)
foundChild := false
for _, res := range results {
if res.LogPath != "" {
_ = os.Remove(res.LogPath)
}
if res.TaskID != child {
continue
}
foundChild = true
if res.ExitCode == 0 || !strings.Contains(res.Error, "skipped") {
t.Fatalf("expected skipped child task result, got %+v", res)
}
}
if !foundChild {
t.Fatalf("expected child task to be present in results")
}
})
t.Run("panicRecovered", func(t *testing.T) {
taskID := nextExecutorTestTaskID("panic")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
panic("boom")
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskID}}}, 1, 0)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].ExitCode == 0 || !strings.Contains(results[0].Error, "panic") {
t.Fatalf("expected panic result, got %+v", results[0])
}
if results[0].LogPath == "" {
t.Fatalf("expected LogPath on panic result")
}
_ = os.Remove(results[0].LogPath)
})
t.Run("cancelWhileWaitingForWorker", func(t *testing.T) {
task1 := nextExecutorTestTaskID("slot")
task2 := nextExecutorTestTaskID("slot")
parentCtx, cancel := context.WithCancel(context.Background())
started := make(chan struct{})
unblock := make(chan struct{})
var startedOnce sync.Once
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
startedOnce.Do(func() { close(started) })
<-unblock
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
go func() {
<-started
cancel()
time.Sleep(50 * time.Millisecond)
close(unblock)
}()
results := executeConcurrentWithContext(parentCtx, [][]TaskSpec{{{ID: task1}, {ID: task2}}}, 1, 1)
foundCancelled := false
for _, res := range results {
if res.LogPath != "" {
_ = os.Remove(res.LogPath)
}
if res.ExitCode == 130 {
foundCancelled = true
}
}
if !foundCancelled {
t.Fatalf("expected a task to be cancelled")
}
})
t.Run("loggerCreateFails", func(t *testing.T) {
taskID := nextExecutorTestTaskID("bad") + "/id"
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskID}}}, 1, 0)
if len(results) != 1 || results[0].ExitCode != 0 {
t.Fatalf("unexpected results: %+v", results)
}
})
t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) {
// Create a writable temp dir for the main logger, then flip TMPDIR to a read-only
// location so task-specific loggers fail to open.
writable := setTempDirEnv(t, t.TempDir())
mainLogger, err := NewLoggerWithSuffix("shared-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
t.Cleanup(func() {
mainLogger.Flush()
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
})
notDir := filepath.Join(writable, "not-a-dir")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
setTempDirEnv(t, notDir)
taskA := nextExecutorTestTaskID("shared-a")
taskB := nextExecutorTestTaskID("shared-b")
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context)
if logger != mainLogger {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "unexpected logger"}
}
logger.Info("TASK=" + task.ID)
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
oldStderr := os.Stderr
os.Stderr = stderrW
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: taskA}, {ID: taskB}}}, 1, 0)
_ = stderrW.Close()
os.Stderr = oldStderr
stderrData, _ := io.ReadAll(stderrR)
_ = stderrR.Close()
stderrOut := string(stderrData)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("task failed unexpectedly: %+v", res)
}
if res.LogPath != mainLogger.Path() {
t.Fatalf("shared log path mismatch: got %q want %q", res.LogPath, mainLogger.Path())
}
if !strings.Contains(stderrOut, "Log (shared)") {
t.Fatalf("stderr missing shared marker: %s", stderrOut)
}
}
// Test full output mode for shared marker (summary mode doesn't show it)
summary := generateFinalOutputWithMode(results, false)
if !strings.Contains(summary, "(shared)") {
t.Fatalf("full output missing shared marker: %s", summary)
}
mainLogger.Flush()
data, err := os.ReadFile(mainLogger.Path())
if err != nil {
t.Fatalf("failed to read main log: %v", err)
}
content := string(data)
if !strings.Contains(content, "TASK="+taskA) || !strings.Contains(content, "TASK="+taskB) {
t.Fatalf("expected shared log to contain both tasks, got: %s", content)
}
})
t.Run("TestSanitizeTaskID", func(t *testing.T) {
setTempDirEnv(t, t.TempDir())
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
logger := executor.TaskLoggerFromContext(task.Context)
if logger == nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: "missing logger"}
}
logger.Info("TASK=" + task.ID)
return TaskResult{TaskID: task.ID, ExitCode: 0}
}
t.Cleanup(func() { runCodexTaskFn = orig })
idA := "../bad id"
idB := "tab\tid"
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: idA}, {ID: idB}}}, 1, 0)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
expected := map[string]string{
idA: sanitizeLogSuffix(idA),
idB: sanitizeLogSuffix(idB),
}
for _, res := range results {
if res.ExitCode != 0 || res.Error != "" {
t.Fatalf("unexpected failure: %+v", res)
}
safe, ok := expected[res.TaskID]
if !ok {
t.Fatalf("unexpected task id %q in results", res.TaskID)
}
wantBase := fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), safe)
if filepath.Base(res.LogPath) != wantBase {
t.Fatalf("log filename for %q = %q, want %q", res.TaskID, filepath.Base(res.LogPath), wantBase)
}
data, err := os.ReadFile(res.LogPath)
if err != nil {
t.Fatalf("failed to read log %q: %v", res.LogPath, err)
}
found := false
for _, line := range strings.Split(string(data), "\n") {
if strings.Contains(stripTimestampPrefix(line), "TASK="+res.TaskID) {
found = true
break
}
}
if !found {
t.Fatalf("log for %q missing task marker, content: %s", res.TaskID, string(data))
}
_ = os.Remove(res.LogPath)
}
})
}
func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatalf("failed to open %s: %v", os.DevNull, err)
}
oldStderr := os.Stderr
os.Stderr = devNull
t.Cleanup(func() {
os.Stderr = oldStderr
_ = devNull.Close()
})
tempDir := setTempDirEnv(t, t.TempDir())
// Setup: 创建主 logger
mainLogger, err := NewLoggerWithSuffix("shared-main")
if err != nil {
t.Fatalf("NewLoggerWithSuffix() error = %v", err)
}
setLogger(mainLogger)
defer func() {
_ = closeLogger()
_ = os.Remove(mainLogger.Path())
}()
// 模拟场景task logger 创建失败(通过设置只读的 TMPDIR
// 回退到主 loggerhandle.shared=true
// 但 runCodexTaskFn 返回自定义的 LogPath不等于主 logger 的路径)
notDir := filepath.Join(tempDir, "not-a-dir")
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
setTempDirEnv(t, notDir)
orig := runCodexTaskFn
customLogPath := "/custom/path/to.log"
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
// 返回自定义 LogPath不等于主 logger 的路径
return TaskResult{
TaskID: task.ID,
ExitCode: 0,
LogPath: customLogPath,
}
}
defer func() { runCodexTaskFn = orig }()
// 执行任务
results := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: "task1"}}}, 1, 0)
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
res := results[0]
out := generateFinalOutputWithMode(results, false)
if strings.Contains(out, "(shared)") {
t.Fatalf("did not expect shared marker when LogPath differs from shared logger, got: %s", out)
}
// 验证 LogPath 确实是自定义的
if res.LogPath != customLogPath {
t.Fatalf("expected custom LogPath %s, got %s", customLogPath, res.LogPath)
}
}