iop/apps/node/internal/adapters/cli/persistent_process_test.go
toki 4a76851254 feat(node): 연결 supervisor 및 CLI adaptors 개선
- runtimeSupervisor 단일 goroutine으로 연결·재연결 수명주기 관리
- 초기 연결 실패 시 fx OnStart 후크 중단 방지 (SDD S16)
- finite 재연결 시도 고갈 시 노드 종료
- CLI adaptors: oneshot, opencode_sse, persistent 보강 및 테스트 확장
2026-07-22 18:11:06 +09:00

324 lines
9.4 KiB
Go

package cli_test
import (
"context"
"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/go/config"
"strings"
"testing"
"time"
)
func TestCLIExecutePersistentWaitsForSlowFirstOutput(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.2; 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)
}
defer func() { _ = c.Stop(ctx) }()
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
err := c.Execute(execCtx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "slow-echo",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err != nil {
t.Fatalf("execute: %v", err)
}
events := sink.Events()
if events[len(events)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("expected last event to be complete, got %q", events[len(events)-1].Type)
}
if combined := testutil.CollectDeltas(events); !strings.Contains(combined, "reply:hello") {
t.Fatalf("expected reply:hello in deltas, got %q", combined)
}
}
func TestCLIExecutePersistentProcessExitReturnsError(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"exit-on-input": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "before-exit\n"; exit 2; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 500,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "exit-on-input",
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("expected non-nil error when persistent process exits")
}
errStr := err.Error()
t.Logf("Got error: %q", errStr)
if !strings.Contains(errStr, "before-exit") {
t.Errorf("expected error to contain recent output 'before-exit', got %q", errStr)
}
if !strings.Contains(errStr, "exit status 2") {
t.Errorf("expected error to contain exit status 'exit status 2', got %q", errStr)
}
if !strings.Contains(errStr, "target=exit-on-input") {
t.Errorf("expected error to contain target=exit-on-input, got %q", errStr)
}
events := sink.Events()
var errorEvent noderuntime.RuntimeEvent
var hasError bool
for _, e := range events {
if e.Type == noderuntime.EventTypeError {
hasError = true
errorEvent = e
}
}
if !hasError {
t.Fatal("expected error event in emitted events")
}
if !strings.Contains(errorEvent.Error, "before-exit") {
t.Errorf("expected error event to contain recent output, got %q", errorEvent.Error)
}
if len(events) > 0 && events[len(events)-1].Type == noderuntime.EventTypeComplete {
t.Fatal("last event should not be complete when process exits unexpectedly")
}
}
func TestCLIExecutePersistentConsecutiveExecutes(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"consecutive-echo": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
if err := c.Start(ctx); err != nil {
t.Fatalf("start: %v", err)
}
defer func() { _ = c.Stop(ctx) }()
sink1 := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "consecutive-echo",
Input: map[string]any{"prompt": "first"},
}, sink1)
if err != nil {
t.Fatalf("first execute: %v", err)
}
events1 := sink1.Events()
if events1[len(events1)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("first run: expected last event to be complete, got %q", events1[len(events1)-1].Type)
}
sink2 := &testutil.FakeSink{}
err = c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-2",
Target: "consecutive-echo",
Input: map[string]any{"prompt": "second"},
}, sink2)
if err != nil {
t.Fatalf("second execute: %v", err)
}
events2 := sink2.Events()
if events2[len(events2)-1].Type != noderuntime.EventTypeComplete {
t.Fatalf("second run: expected last event to be complete, got %q", events2[len(events2)-1].Type)
}
for _, e := range events2 {
if e.Type == noderuntime.EventTypeDelta && strings.Contains(e.Delta, "first") {
t.Fatalf("second run received first run's delta (session contamination): %q", e.Delta)
}
}
}
func TestCLIExecutePersistentCreatesIndependentSessionsForSameProfile(t *testing.T) {
testutil.RequirePTYSupport(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"multi-echo": {
Command: "sh",
Args: []string{"-c", `stty -echo; while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 200,
StartupIdleTimeoutMS: 50,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
sinkA := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-a",
Target: "multi-echo",
SessionID: "session-a",
Input: map[string]any{"prompt": "alpha"},
}, sinkA)
if err != nil {
t.Fatalf("session-a execute: %v", err)
}
sinkB := &testutil.FakeSink{}
err = c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-b",
Target: "multi-echo",
SessionID: "session-b",
Input: map[string]any{"prompt": "beta"},
}, sinkB)
if err != nil {
t.Fatalf("session-b execute: %v", err)
}
if combined := testutil.CollectDeltas(sinkA.Events()); !strings.Contains(combined, "alpha") {
t.Fatalf("session-a: expected alpha in deltas, got %q", combined)
}
if combined := testutil.CollectDeltas(sinkB.Events()); !strings.Contains(combined, "beta") {
t.Fatalf("session-b: expected beta in deltas, got %q", combined)
}
if combined := testutil.CollectDeltas(sinkA.Events()); strings.Contains(combined, "beta") {
t.Fatalf("session-a contaminated with session-b output: %q", combined)
}
}
func TestCLIExecutePersistentRequireExistingSessionFailsWhenMissing(t *testing.T) {
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"echo-req": {
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()
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-1",
Target: "echo-req",
SessionID: "nonexistent",
SessionMode: noderuntime.SessionModeRequireExisting,
Input: map[string]any{"prompt": "hello"},
}, sink)
if err == nil {
t.Fatal("expected error for require-existing with missing session")
}
if strings.Contains(err.Error(), "start") {
t.Fatalf("should not have started a new process, got: %v", err)
}
}
func TestCLIExecutePersistentMaintainsHundredLogicalSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping heavy session test in short mode")
}
testutil.RequireUnixShell(t)
cfg := config.CLIConf{
Enabled: true,
Profiles: map[string]config.CLIProfileConf{
"hundred-echo": {
Command: "sh",
Args: []string{"-c", `while IFS= read -r line; do printf "reply:%s\n" "$line"; done`},
Persistent: true,
Terminal: false,
ResponseIdleTimeoutMS: 300,
},
},
}
c := clipkg.New(cfg, zap.NewNop())
ctx := context.Background()
const n = 20
for i := 0; i < n; i++ {
sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26))
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-first-" + sessionID,
Target: "hundred-echo",
SessionID: sessionID,
Input: map[string]any{"prompt": "ping-" + sessionID},
}, sink)
if err != nil {
t.Fatalf("session %q first execute: %v", sessionID, err)
}
}
// Second round — all sessions must still be alive.
for i := 0; i < n; i++ {
sessionID := "session-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26))
sink := &testutil.FakeSink{}
err := c.Execute(ctx, noderuntime.ExecutionSpec{
RunID: "run-second-" + sessionID,
Target: "hundred-echo",
SessionID: sessionID,
Input: map[string]any{"prompt": "pong-" + sessionID},
}, sink)
if err != nil {
t.Fatalf("session %q second execute: %v", sessionID, err)
}
}
_ = c.Stop(ctx)
}