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) for _, model := range []string{"ok-profile", "fail-profile"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Model: model, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup, 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) for _, model := range []string{"Z-alive", "Z-fail"} { sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-cleanup", Model: model, Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatalf("expected Execute for %q to fail after cleanup, 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 }) sink := &testutil.FakeSink{} if err := c.Execute(ctx, noderuntime.ExecutionSpec{ RunID: "post-rollback", Model: "profile-a", Input: map[string]any{"prompt": "test"}, }, sink); err == nil { t.Fatal("expected Execute for profile-a to fail with no-session after rollback") } }