源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,892 @@
|
||||
package agentruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- per-process app-server transport ---
|
||||
|
||||
// multiProcAppServerTransport spawns a fresh scripted connection per Start so
|
||||
// tests can count how many app-server OS processes a session spawned and how
|
||||
// many are still live. The shared-conn scriptedAppServerTransport cannot
|
||||
// distinguish two processes because every Start reuses the same connection.
|
||||
type multiProcAppServerTransport struct {
|
||||
mu sync.Mutex
|
||||
conns []*scriptedAppServerConnection
|
||||
specs []ProcessSpec
|
||||
startErr error
|
||||
configure func(server *fakeCodexAppServer)
|
||||
}
|
||||
|
||||
func (t *multiProcAppServerTransport) Start(_ context.Context, spec ProcessSpec) (ProcessConnection, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.startErr != nil {
|
||||
return nil, t.startErr
|
||||
}
|
||||
conn, server := newScriptedAppServerHarness()
|
||||
if t.configure != nil {
|
||||
t.configure(server)
|
||||
}
|
||||
t.conns = append(t.conns, conn)
|
||||
t.specs = append(t.specs, spec)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (t *multiProcAppServerTransport) setStartErr(err error) {
|
||||
t.mu.Lock()
|
||||
t.startErr = err
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *multiProcAppServerTransport) setConfigure(configure func(server *fakeCodexAppServer)) {
|
||||
t.mu.Lock()
|
||||
t.configure = configure
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// snapshot returns how many processes were spawned in total and which of them
|
||||
// are still live (never closed).
|
||||
func (t *multiProcAppServerTransport) snapshot() (spawned int, live []*scriptedAppServerConnection) {
|
||||
t.mu.Lock()
|
||||
conns := append([]*scriptedAppServerConnection(nil), t.conns...)
|
||||
t.mu.Unlock()
|
||||
for _, conn := range conns {
|
||||
conn.mu.Lock()
|
||||
closed := conn.closeCount > 0
|
||||
conn.mu.Unlock()
|
||||
if !closed {
|
||||
live = append(live, conn)
|
||||
}
|
||||
}
|
||||
return len(conns), live
|
||||
}
|
||||
|
||||
func (t *multiProcAppServerTransport) conn(index int) *scriptedAppServerConnection {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if index < 0 || index >= len(t.conns) {
|
||||
return nil
|
||||
}
|
||||
return t.conns[index]
|
||||
}
|
||||
|
||||
func (t *multiProcAppServerTransport) spec(index int) ProcessSpec {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if index < 0 || index >= len(t.specs) {
|
||||
return ProcessSpec{}
|
||||
}
|
||||
return t.specs[index]
|
||||
}
|
||||
|
||||
func connClosed(conn *scriptedAppServerConnection) bool {
|
||||
if conn == nil {
|
||||
return false
|
||||
}
|
||||
conn.mu.Lock()
|
||||
defer conn.mu.Unlock()
|
||||
return conn.closeCount > 0
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterProviderLaunchPrepareMutatesSpecAndCleansUpOnClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := newScriptedAppServerTransport()
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
cleanupCalls := 0
|
||||
adapter.SetProviderLaunchPreparer(func(_ context.Context, input ProviderLaunchPrepareInput) (ProviderLaunchPrepareResult, error) {
|
||||
if input.Provider != ProviderCodex {
|
||||
t.Fatalf("Provider = %q, want %q", input.Provider, ProviderCodex)
|
||||
}
|
||||
if input.DirectStart {
|
||||
t.Fatal("DirectStart = true, want false for codex app-server")
|
||||
}
|
||||
if input.Session.AgentSessionID != "agent-session-1" {
|
||||
t.Fatalf("Session.AgentSessionID = %q", input.Session.AgentSessionID)
|
||||
}
|
||||
return ProviderLaunchPrepareResult{
|
||||
Command: []string{"prepared-codex", "app-server"},
|
||||
Env: append(append([]string(nil), input.Env...), "HOOK_ENV=1"),
|
||||
CWD: "/prepared/workspace",
|
||||
Cleanup: func(context.Context) error {
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
session := testAppServerSession()
|
||||
session.Env = []string{"SESSION_ENV=1"}
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if cleanupCalls != 0 {
|
||||
t.Fatalf("cleanup calls before close = %d, want 0", cleanupCalls)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
specs := append([]ProcessSpec(nil), transport.specs...)
|
||||
transport.mu.Unlock()
|
||||
if len(specs) != 1 {
|
||||
t.Fatalf("transport starts = %d, want 1", len(specs))
|
||||
}
|
||||
spec := specs[0]
|
||||
if !reflect.DeepEqual(spec.Command, []string{"prepared-codex", "app-server"}) {
|
||||
t.Fatalf("Command = %#v", spec.Command)
|
||||
}
|
||||
if spec.CWD != "/prepared/workspace" {
|
||||
t.Fatalf("CWD = %q", spec.CWD)
|
||||
}
|
||||
if !reflect.DeepEqual(spec.Env[len(spec.Env)-4:], []string{
|
||||
"SESSION_ENV=1",
|
||||
"HOOK_ENV=1",
|
||||
codexAppServerLogFormatEnv,
|
||||
codexAppServerRustLogEnv,
|
||||
}) {
|
||||
t.Fatalf("Env tail = %#v", spec.Env)
|
||||
}
|
||||
|
||||
if err := adapter.Close(context.Background(), session); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("cleanup calls after close = %d, want 1", cleanupCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterStartUsesInjectedProviderCommand(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := newScriptedAppServerTransport()
|
||||
adapter := NewCodexAppServerAdapterWithHostMetadataAndCommandResolver(
|
||||
transport,
|
||||
LegacyHostMetadata(),
|
||||
func(_ context.Context, provider string) (ProviderCommand, error) {
|
||||
if provider != ProviderCodex {
|
||||
t.Fatalf("provider = %q, want %q", provider, ProviderCodex)
|
||||
}
|
||||
return ProviderCommand{
|
||||
Command: []string{"/user/bin/codex", "app-server"},
|
||||
Env: []string{"PATH=/managed/node/bin:/user/bin", "TUTTI_APP_NODE=/managed/node/bin/node"},
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
session := testAppServerSession()
|
||||
session.Env = []string{"SESSION_ENV=1"}
|
||||
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if len(transport.specs) != 1 {
|
||||
t.Fatalf("process starts = %d, want 1", len(transport.specs))
|
||||
}
|
||||
spec := transport.specs[0]
|
||||
if !reflect.DeepEqual(spec.Command, []string{"/user/bin/codex", "app-server"}) {
|
||||
t.Fatalf("Command = %#v", spec.Command)
|
||||
}
|
||||
if !containsString(spec.Env, "SESSION_ENV=1") || !containsString(spec.Env, "TUTTI_APP_NODE=/managed/node/bin/node") {
|
||||
t.Fatalf("Env = %#v, want session and managed runtime env", spec.Env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterProviderLaunchPrepareFailureDoesNotSpawn(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
prepareErr := errors.New("prepare failed")
|
||||
adapter.SetProviderLaunchPreparer(func(context.Context, ProviderLaunchPrepareInput) (ProviderLaunchPrepareResult, error) {
|
||||
return ProviderLaunchPrepareResult{}, prepareErr
|
||||
})
|
||||
|
||||
if _, err := adapter.Start(context.Background(), testAppServerSession()); !errors.Is(err, prepareErr) {
|
||||
t.Fatalf("Start error = %v, want %v", err, prepareErr)
|
||||
}
|
||||
spawned, _ := transport.snapshot()
|
||||
if spawned != 0 {
|
||||
t.Fatalf("spawned processes = %d, want 0", spawned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterProviderLaunchCleanupRunsOnProcessStartFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
startErr := errors.New("process start failed")
|
||||
transport.setStartErr(startErr)
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
cleanupCalls := 0
|
||||
cleanupSawCanceledContext := false
|
||||
adapter.SetProviderLaunchPreparer(func(_ context.Context, input ProviderLaunchPrepareInput) (ProviderLaunchPrepareResult, error) {
|
||||
return ProviderLaunchPrepareResult{
|
||||
Command: input.Command,
|
||||
Env: input.Env,
|
||||
CWD: input.CWD,
|
||||
Cleanup: func(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
cleanupSawCanceledContext = true
|
||||
}
|
||||
cleanupCalls++
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := adapter.Start(ctx, testAppServerSession()); !errors.Is(err, startErr) {
|
||||
t.Fatalf("Start error = %v, want %v", err, startErr)
|
||||
}
|
||||
if cleanupCalls != 1 {
|
||||
t.Fatalf("cleanup calls = %d, want 1", cleanupCalls)
|
||||
}
|
||||
if cleanupSawCanceledContext {
|
||||
t.Fatal("cleanup received canceled launch context")
|
||||
}
|
||||
}
|
||||
|
||||
// --- single-live-process invariant tests ---
|
||||
|
||||
func TestCodexAppServerAdapterConcurrentStartsLeaveSingleLiveProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
for i := range errs {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = adapter.Start(context.Background(), session)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("Start[%d]: %v", i, err)
|
||||
}
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("live app-server processes = %d (spawned %d), want exactly 1", len(live), spawned)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatalf("HasLiveSession = false, want true after concurrent starts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterStartOverLiveSessionStopsPreviousProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("second Start: %v", err)
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 2 {
|
||||
t.Fatalf("spawned processes = %d, want 2", spawned)
|
||||
}
|
||||
if len(live) != 1 || live[0] != transport.conn(1) {
|
||||
t.Fatalf("live processes = %d, want exactly the replacement process live", len(live))
|
||||
}
|
||||
if !connClosed(transport.conn(0)) {
|
||||
t.Fatalf("previous app-server process was orphaned instead of closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterResumeOverLiveSessionClosesPreviousProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if err := adapter.Resume(context.Background(), session); err != nil {
|
||||
t.Fatalf("Resume: %v", err)
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 2 {
|
||||
t.Fatalf("spawned processes = %d, want 2", spawned)
|
||||
}
|
||||
if len(live) != 1 || live[0] != transport.conn(1) {
|
||||
t.Fatalf("live processes = %d, want exactly the resumed process live", len(live))
|
||||
}
|
||||
if !connClosed(transport.conn(0)) {
|
||||
t.Fatalf("pre-resume app-server process was orphaned instead of closed")
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatalf("HasLiveSession = false, want true after resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterResumeSpawnFailureKeepsPreviousSessionLive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
transport.setStartErr(errors.New("spawn failed"))
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if err := adapter.Resume(context.Background(), session); err == nil {
|
||||
t.Fatalf("Resume with failing spawn should error")
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 1 || len(live) != 1 || live[0] != transport.conn(0) {
|
||||
t.Fatalf("spawned=%d live=%d, want the original process to stay live", spawned, len(live))
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatalf("HasLiveSession = false, want true: failed resume must keep the old session usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterResumeThreadFailureKeepsPreviousSessionLive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
transport.setConfigure(func(server *fakeCodexAppServer) {
|
||||
server.threadResumeError = true
|
||||
})
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if err := adapter.Resume(context.Background(), session); err == nil {
|
||||
t.Fatalf("Resume with failing thread/resume should error")
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 2 {
|
||||
t.Fatalf("spawned processes = %d, want 2", spawned)
|
||||
}
|
||||
if len(live) != 1 || live[0] != transport.conn(0) {
|
||||
t.Fatalf("live processes = %d, want only the original process live (new one closed)", len(live))
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatalf("HasLiveSession = false, want true: failed resume must keep the old session usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterResumeMCPAuthFailureDoesNotBlockLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
transport.setConfigure(func(server *fakeCodexAppServer) {
|
||||
server.mcpAuthStderrOnResume = true
|
||||
server.mcpAuthStderrResumeResponse = true
|
||||
server.mcpFailureResponseDelay = 1100 * time.Millisecond
|
||||
})
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
startedAt := time.Now()
|
||||
err := adapter.Resume(context.Background(), session)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume with optional MCP auth failure: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed >= 3*time.Second {
|
||||
t.Fatalf("Resume took %s; MCP failure blocked lifecycle", elapsed)
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 2 || len(live) != 1 || live[0] != transport.conn(1) {
|
||||
t.Fatalf("spawned=%d live=%d, want only the resumed process live", spawned, len(live))
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("HasLiveSession = false after optional MCP startup failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterStartMCPAuthFailureDoesNotBlockLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
transport.setConfigure(func(server *fakeCodexAppServer) {
|
||||
server.mcpAuthStderrOnStart = true
|
||||
server.mcpAuthStderrStartResponse = true
|
||||
server.mcpFailureResponseDelay = 1100 * time.Millisecond
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if _, err := adapter.Start(ctx, testAppServerSession()); err != nil {
|
||||
t.Fatalf("Start with optional MCP auth failure: %v", err)
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if spawned != 1 || len(live) != 1 || live[0] != transport.conn(0) {
|
||||
t.Fatalf("spawned=%d live=%d, want the started process to remain live", spawned, len(live))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterMCPStartupStatusFailureDoesNotBlockLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(*fakeCodexAppServer)
|
||||
resume bool
|
||||
wantLiveIndex int
|
||||
}{
|
||||
{
|
||||
name: "start",
|
||||
configure: func(server *fakeCodexAppServer) {
|
||||
server.mcpStartupStatusFailedOnStart = true
|
||||
server.mcpStartupStatusFailureResponse = true
|
||||
server.mcpFailureResponseDelay = 1100 * time.Millisecond
|
||||
},
|
||||
wantLiveIndex: 0,
|
||||
},
|
||||
{
|
||||
name: "resume",
|
||||
configure: func(server *fakeCodexAppServer) {
|
||||
server.mcpStartupStatusFailedOnResume = true
|
||||
server.mcpStartupStatusFailureResponse = true
|
||||
server.mcpFailureResponseDelay = 1100 * time.Millisecond
|
||||
},
|
||||
resume: true,
|
||||
wantLiveIndex: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if test.resume {
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
}
|
||||
transport.setConfigure(test.configure)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
startedAt := time.Now()
|
||||
var err error
|
||||
if test.resume {
|
||||
err = adapter.Resume(ctx, session)
|
||||
} else {
|
||||
_, err = adapter.Start(ctx, session)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("MCP startup status failure blocked lifecycle: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(startedAt); elapsed >= 3*time.Second {
|
||||
t.Fatalf("lifecycle call took %s; MCP failure blocked lifecycle", elapsed)
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
wantSpawned := 1
|
||||
if test.resume {
|
||||
wantSpawned = 2
|
||||
}
|
||||
if spawned != wantSpawned || len(live) != 1 || live[0] != transport.conn(test.wantLiveIndex) {
|
||||
t.Fatalf("spawned=%d live=%d, want process %d to remain live", spawned, len(live), test.wantLiveIndex)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("HasLiveSession = false after optional MCP startup failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterMCPStartupStatusResponseWinsGraceRace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
transport.setConfigure(func(server *fakeCodexAppServer) {
|
||||
server.mcpStartupStatusFailedOnResume = true
|
||||
server.mcpStartupStatusFailureResponse = true
|
||||
})
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if err := adapter.Resume(context.Background(), session); err != nil {
|
||||
t.Fatalf("Resume response lost MCP startup failure grace race: %v", err)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("HasLiveSession = false after successful resume")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterThreadStartedNotificationCompletesLifecycleWithoutRPCResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resume bool
|
||||
configure func(*fakeCodexAppServer)
|
||||
}{
|
||||
{
|
||||
name: "start",
|
||||
configure: func(server *fakeCodexAppServer) {
|
||||
server.threadStartedOnStart = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resume",
|
||||
resume: true,
|
||||
configure: func(server *fakeCodexAppServer) {
|
||||
server.threadStartedOnResume = true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if test.resume {
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
}
|
||||
transport.setConfigure(test.configure)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if test.resume {
|
||||
if err := adapter.Resume(ctx, session); err != nil {
|
||||
t.Fatalf("Resume should use thread/started as lifecycle success: %v", err)
|
||||
}
|
||||
} else if _, err := adapter.Start(ctx, session); err != nil {
|
||||
t.Fatalf("Start should use thread/started as lifecycle success: %v", err)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("HasLiveSession = false after notification-only lifecycle success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerAdapterStartReleaseRaceLeavesNoOrphanProcess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for iteration := 0; iteration < 25; iteration++ {
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if _, err := adapter.Start(context.Background(), session); err != nil {
|
||||
t.Fatalf("iteration %d Start: %v", iteration, err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = adapter.Start(context.Background(), session)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = adapter.ReleaseLiveSession(context.Background(), session)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
wantLive := 0
|
||||
if adapter.HasLiveSession(session) {
|
||||
wantLive = 1
|
||||
}
|
||||
spawned, live := transport.snapshot()
|
||||
if len(live) != wantLive {
|
||||
t.Fatalf("iteration %d: live processes = %d, want %d (spawned %d): process leaked or half-closed",
|
||||
iteration, len(live), wantLive, spawned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerClientCloseIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := newScriptedAppServerConnection()
|
||||
client := newCodexAppServerClient(conn)
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := client.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
conn.mu.Lock()
|
||||
closeCount := conn.closeCount
|
||||
conn.mu.Unlock()
|
||||
if closeCount != 1 {
|
||||
t.Fatalf("underlying connection Close calls = %d, want 1 (client Close must be idempotent)", closeCount)
|
||||
}
|
||||
}
|
||||
|
||||
type sequencedCloseErrorConnection struct {
|
||||
ProcessConnection
|
||||
mu sync.Mutex
|
||||
errors []error
|
||||
}
|
||||
|
||||
func (c *sequencedCloseErrorConnection) Close() error {
|
||||
c.mu.Lock()
|
||||
var err error
|
||||
if len(c.errors) > 0 {
|
||||
err = c.errors[0]
|
||||
c.errors = c.errors[1:]
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if err == nil {
|
||||
return c.ProcessConnection.Close()
|
||||
}
|
||||
if errors.Is(err, ErrSessionNotFound) {
|
||||
// The sentinel represents a connection whose provider session has
|
||||
// already disappeared. Close the scripted transport for test cleanup,
|
||||
// while preserving the provider error returned to the adapter.
|
||||
_ = c.ProcessConnection.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type sequencedCloseErrorTransport struct {
|
||||
mu sync.Mutex
|
||||
starts int
|
||||
closeErrors []error
|
||||
}
|
||||
|
||||
func (t *sequencedCloseErrorTransport) Start(_ context.Context, _ ProcessSpec) (ProcessConnection, error) {
|
||||
conn, _ := newScriptedAppServerHarness()
|
||||
t.mu.Lock()
|
||||
start := t.starts
|
||||
t.starts++
|
||||
closeErrors := append([]error(nil), t.closeErrors...)
|
||||
t.mu.Unlock()
|
||||
if start == 0 {
|
||||
return &sequencedCloseErrorConnection{
|
||||
ProcessConnection: conn,
|
||||
errors: closeErrors,
|
||||
}, nil
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func TestAppServerAlreadyGoneRetiredHandleDoesNotBackpressureReplacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &sequencedCloseErrorTransport{
|
||||
closeErrors: []error{
|
||||
errors.New("injected first close failure"),
|
||||
errors.New("injected second close failure"),
|
||||
ErrSessionNotFound,
|
||||
},
|
||||
}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
if _, err := adapter.Start(t.Context(), session); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
if _, err := adapter.Start(t.Context(), session); err != nil {
|
||||
t.Fatalf("replacement Start: %v", err)
|
||||
}
|
||||
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if err := adapter.Resume(t.Context(), session); err != nil {
|
||||
t.Fatalf("Resume after provider session disappeared: %v", err)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("replacement app-server session is not usable")
|
||||
}
|
||||
cleanup := adapter.CleanupLiveSessionResources(t.Context(), 1)
|
||||
if cleanup.Attempted != 0 || cleanup.Cleaned != 0 || cleanup.Failed != 0 {
|
||||
t.Fatalf("cleanup=%#v, want no retained session", cleanup)
|
||||
}
|
||||
if err := adapter.ReleaseLiveSession(t.Context(), session); err != nil {
|
||||
t.Fatalf("release replacement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppServerCloseTreatsMissingSessionAsSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
connection, _ := newScriptedAppServerHarness()
|
||||
client := newCodexAppServerClient(&sequencedCloseErrorConnection{
|
||||
ProcessConnection: connection,
|
||||
errors: []error{ErrSessionNotFound},
|
||||
})
|
||||
adapter := NewCodexAppServerAdapter(&multiProcAppServerTransport{})
|
||||
session := testAppServerSession()
|
||||
adapter.storeSession(session.AgentSessionID, &codexAppServerSession{
|
||||
client: client,
|
||||
pendingRequests: make(map[string]*pendingInteractiveRequest),
|
||||
})
|
||||
|
||||
if err := adapter.ReleaseLiveSession(t.Context(), session); err != nil {
|
||||
t.Fatalf("ReleaseLiveSession: %v", err)
|
||||
}
|
||||
if adapter.getSession(session.AgentSessionID) != nil {
|
||||
t.Fatal("missing provider session remained owned as current")
|
||||
}
|
||||
if adapter.hasRetiredCodexSessions(session.AgentSessionID) {
|
||||
t.Fatal("missing provider session was retained for cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppServerCloseFailureRetainsUnusableHandleAndBackpressuresReplacement(t *testing.T) {
|
||||
for _, provider := range []string{ProviderCodex, ProviderTuttiAgent} {
|
||||
provider := provider
|
||||
t.Run(provider, func(t *testing.T) {
|
||||
transport := &multiProcAppServerTransport{}
|
||||
var adapter *CodexAppServerAdapter
|
||||
if provider == ProviderTuttiAgent {
|
||||
adapter = NewTuttiAgentAppServerAdapterWithHostMetadata(transport, LegacyHostMetadata())
|
||||
} else {
|
||||
adapter = NewCodexAppServerAdapter(transport)
|
||||
}
|
||||
session := testAppServerSession()
|
||||
session.Provider = provider
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
if _, err := adapter.Start(t.Context(), session); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
oldConnection := transport.conn(0)
|
||||
oldConnection.mu.Lock()
|
||||
oldConnection.closeFailures = 4
|
||||
oldConnection.mu.Unlock()
|
||||
|
||||
if err := adapter.ReleaseLiveSession(t.Context(), session); err == nil {
|
||||
t.Fatal("ReleaseLiveSession error=nil, want close failure")
|
||||
}
|
||||
if adapter.HasLiveSession(session) {
|
||||
t.Fatal("close-failed client remained usable")
|
||||
}
|
||||
if err := adapter.Resume(t.Context(), session); err != nil {
|
||||
t.Fatalf("first replacement Resume: %v", err)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("replacement client is not usable")
|
||||
}
|
||||
if err := adapter.ReleaseLiveSession(t.Context(), session); err != nil {
|
||||
t.Fatalf("release replacement: %v", err)
|
||||
}
|
||||
spawnedBefore, _ := transport.snapshot()
|
||||
err := adapter.Resume(t.Context(), session)
|
||||
if AppErrorCode(err) != AppErrorProcessCleanupPending {
|
||||
t.Fatalf("second Resume error code=%q err=%v", AppErrorCode(err), err)
|
||||
}
|
||||
spawnedAfter, _ := transport.snapshot()
|
||||
if spawnedAfter != spawnedBefore {
|
||||
t.Fatalf("spawned processes changed behind cleanup backpressure: %d -> %d", spawnedBefore, spawnedAfter)
|
||||
}
|
||||
cleanupAdapter, ok := any(adapter).(LiveSessionResourceCleanupAdapter)
|
||||
if !ok {
|
||||
t.Fatal("app-server adapter does not own detached resource cleanup")
|
||||
}
|
||||
cleanup := cleanupAdapter.CleanupLiveSessionResources(t.Context(), 1)
|
||||
if cleanup.Attempted != 1 || cleanup.Failed != 1 {
|
||||
t.Fatalf("failed cleanup=%#v", cleanup)
|
||||
}
|
||||
oldConnection.mu.Lock()
|
||||
oldConnection.closeFailures = 0
|
||||
oldConnection.mu.Unlock()
|
||||
cleanup = cleanupAdapter.CleanupLiveSessionResources(t.Context(), 1)
|
||||
if cleanup.Attempted != 1 || cleanup.Cleaned != 1 || cleanup.Failed != 0 {
|
||||
t.Fatalf("successful cleanup=%#v", cleanup)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type doneClosedCloseFailConnection struct {
|
||||
ProcessConnection
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
closeCalls int
|
||||
}
|
||||
|
||||
func newDoneClosedCloseFailConnection(connection ProcessConnection) *doneClosedCloseFailConnection {
|
||||
done := make(chan struct{})
|
||||
close(done)
|
||||
return &doneClosedCloseFailConnection{ProcessConnection: connection, done: done}
|
||||
}
|
||||
|
||||
func (*doneClosedCloseFailConnection) Recv() (ProcessFrame, error) {
|
||||
return ProcessFrame{}, io.EOF
|
||||
}
|
||||
|
||||
func (c *doneClosedCloseFailConnection) Close() error {
|
||||
c.mu.Lock()
|
||||
c.closeCalls++
|
||||
c.mu.Unlock()
|
||||
return errors.New("injected persistent close failure after process exit")
|
||||
}
|
||||
|
||||
func (c *doneClosedCloseFailConnection) Done() <-chan struct{} { return c.done }
|
||||
|
||||
func TestAppServerExitedConnectionWithPersistentCloseFailureRemainsOwned(t *testing.T) {
|
||||
base, _ := newScriptedAppServerHarness()
|
||||
connection := newDoneClosedCloseFailConnection(base)
|
||||
client := newCodexAppServerClient(connection)
|
||||
transport := &multiProcAppServerTransport{}
|
||||
adapter := NewCodexAppServerAdapter(transport)
|
||||
session := testAppServerSession()
|
||||
session.ProviderSessionID = "codex-thread-1"
|
||||
appSession := &codexAppServerSession{client: client, pendingRequests: make(map[string]*pendingInteractiveRequest)}
|
||||
adapter.storeSession(session.AgentSessionID, appSession)
|
||||
select {
|
||||
case <-client.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("app-server client did not observe the closed process connection")
|
||||
}
|
||||
|
||||
if adapter.HasLiveSession(session) {
|
||||
t.Fatal("Done-closed app-server client reported usable")
|
||||
}
|
||||
if err := adapter.DisconnectLiveSession(t.Context(), session); err == nil {
|
||||
t.Fatal("DisconnectLiveSession error=nil, want persistent Close failure")
|
||||
}
|
||||
if adapter.getSession(session.AgentSessionID) != appSession {
|
||||
t.Fatal("Done-closed close-failed handle lost current ownership")
|
||||
}
|
||||
if err := adapter.Resume(t.Context(), session); err != nil {
|
||||
t.Fatalf("Resume after exited close failure: %v", err)
|
||||
}
|
||||
if !adapter.HasLiveSession(session) {
|
||||
t.Fatal("replacement app-server session is not usable")
|
||||
}
|
||||
if adapter.getSession(session.AgentSessionID) == appSession {
|
||||
t.Fatal("exited close-failed handle remained current after replacement")
|
||||
}
|
||||
cleanup := adapter.CleanupLiveSessionResources(t.Context(), 1)
|
||||
if cleanup.Attempted != 1 || cleanup.Failed != 1 || cleanup.Cleaned != 0 {
|
||||
t.Fatalf("cleanup=%#v, want one retained failure", cleanup)
|
||||
}
|
||||
connection.mu.Lock()
|
||||
closeCalls := connection.closeCalls
|
||||
connection.mu.Unlock()
|
||||
if closeCalls != 3 {
|
||||
t.Fatalf("Close calls=%d, want disconnect + replacement retirement + bounded cleanup", closeCalls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user