package lifecycle_test import ( "context" "fmt" "path/filepath" "testing" "time" "go.uber.org/zap" clipkg "iop/apps/node/internal/adapters/cli" "iop/apps/node/internal/adapters/cli/internal/testutil" noderuntime "iop/apps/node/internal/runtime" "iop/packages/config" ) func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "exits-immediately": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) if err := c.Start(context.Background()); err == nil { t.Fatal("expected error when persistent process exits during startup") } } func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "ok-profile": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "fail-profile": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when one persistent profile exits immediately") } _ = c.Stop(ctx) _ = c.Stop(ctx) // After rollback, the pre-started default session for ok-profile is gone. // Verify with require-existing so lazy creation is not allowed. for _, model := range []string{"ok-profile", "fail-profile"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Model: model, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model) } } } func TestCLIStartDeterministicOrder(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "Z-alive": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "Z-fail": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when one persistent profile exits immediately") } _ = c.Stop(ctx) _ = c.Stop(ctx) // After rollback, pre-started default sessions must be gone. for _, model := range []string{"Z-alive", "Z-fail"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Model: model, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup (require-existing), got nil", model) } } } func TestCLIConcurrentExecuteAndStop(t *testing.T) { testutil.RequirePTYSupport(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "slow-echo": { Command: "sh", Args: []string{"-c", `stty -echo; while IFS= read -r line; do sleep 0.05; printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: true, ResponseIdleTimeoutMS: 50, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err != nil { t.Fatalf("start: %v", err) } errCh := make(chan error, 10) done := make(chan struct{}) go func() { for i := 0; i < 3; i++ { sink := &testutil.FakeSink{} execCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: fmt.Sprintf("run-conc-%d", i), Model: "slow-echo", Input: map[string]any{"prompt": fmt.Sprintf("conc-%d", i)}, }, sink) cancel() if err != nil { errCh <- fmt.Errorf("concurrent execute %d: %w", i, err) } } done <- struct{}{} }() go func() { time.Sleep(200 * time.Millisecond) _ = c.Stop(ctx) done <- struct{}{} }() go func() { time.Sleep(300 * time.Millisecond) for i := 0; i < 2; i++ { sink := &testutil.FakeSink{} execCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) err := c.Execute(execCtx, noderuntime.ExecutionSpec{ RunID: fmt.Sprintf("run-post-stop-%d", i), Model: "slow-echo", Input: map[string]any{"prompt": fmt.Sprintf("post-stop-%d", i)}, }, sink) cancel() if err != nil { errCh <- fmt.Errorf("post-stop execute %d: %w", i, err) } } done <- struct{}{} }() timeout := time.After(10 * time.Second) for completed := 0; completed < 3; completed++ { select { case <-done: case <-timeout: t.Fatal("TestCLIConcurrentExecuteAndStop timed out; possible deadlock") } } close(errCh) for range errCh { } } func TestCLIStartPartialRollbackWithMarkers(t *testing.T) { testutil.RequireUnixShell(t) markerDir := t.TempDir() startMarker := "START_MARKER_A" startMarkerPath := filepath.Join(markerDir, "marker_a") profileACmd := fmt.Sprintf(`mkdir -p %s; echo %s > %s/marker_a; while IFS= read -r line; do printf "reply:%%s\n" "$line"; done`, markerDir, startMarker, markerDir) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "profile-a": { Command: "sh", Args: []string{"-c", profileACmd}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, ResponseIdleTimeoutMS: 200, }, "profile-b": { Command: "sh", Args: []string{"-c", "exit 2"}, Persistent: true, Terminal: true, StartupIdleTimeoutMS: 50, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() if err := c.Start(ctx); err == nil { t.Fatal("expected Start to fail when profile-b exits immediately") } _ = c.Stop(ctx) time.Sleep(300 * time.Millisecond) testutil.RequireEventually(t, 2*time.Second, 50*time.Millisecond, func() bool { return testutil.ReadMarker(startMarkerPath) == startMarker }) // After rollback, the pre-started default session for profile-a is gone. sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-rollback", Model: "profile-a", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatal("expected Execute for profile-a to fail with no-session after rollback (require-existing)") } } func TestCLITerminateSessionStopsOnlyTargetSession(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-term": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() // Create session-a and session-b via lazy creation. for _, sid := range []string{"session-a", "session-b"} { sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-" + sid, Model: "echo-term", SessionID: sid, Input: map[string]any{"prompt": "ping"}, }, sink) if err != nil { t.Fatalf("create %s: %v", sid, err) } } // Terminate session-a only. if err := c.TerminateSession(ctx, "echo-term", "session-a"); err != nil { t.Fatalf("TerminateSession: %v", err) } // session-b must still respond. sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-b-after", Model: "echo-term", SessionID: "session-b", Input: map[string]any{"prompt": "pong"}, }, sink) if err != nil { t.Fatalf("session-b should still be alive: %v", err) } // session-a must be gone (require-existing fails). sinkA := &testutil.FakeSink{} err = c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-a-after", Model: "echo-term", SessionID: "session-a", SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "should-fail"}, }, sinkA) if err == nil { t.Fatal("expected error for terminated session-a with require-existing mode") } _ = c.Stop(ctx) } func TestCLIStopStopsAllLogicalSessions(t *testing.T) { testutil.RequireUnixShell(t) cfg := config.CLIConf{ Enabled: true, Profiles: map[string]config.CLIProfileConf{ "echo-stop": { Command: "sh", Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`}, Persistent: true, Terminal: false, ResponseIdleTimeoutMS: 200, }, }, } c := clipkg.New(cfg, zap.NewNop()) ctx := context.Background() // Create three logical sessions. for _, sid := range []string{"s1", "s2", "s3"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-" + sid, Model: "echo-stop", SessionID: sid, Input: map[string]any{"prompt": "ping"}, }, sink); err != nil { t.Fatalf("create %s: %v", sid, err) } } if err := c.Stop(ctx); err != nil { t.Fatalf("Stop: %v", err) } // After Stop, all sessions must be gone (require-existing must fail). for _, sid := range []string{"s1", "s2", "s3"} { sink := &testutil.FakeSink{} err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "run-after-" + sid, Model: "echo-stop", SessionID: sid, SessionMode: noderuntime.SessionModeRequireExisting, Input: map[string]any{"prompt": "should-fail"}, }, sink) if err == nil { t.Fatalf("session %q should be stopped after Stop()", sid) } } }