package host import ( "context" "errors" "reflect" "sync" "testing" "time" ) type traceLog struct { mu sync.Mutex events []string } func (l *traceLog) add(event string) { l.mu.Lock() defer l.mu.Unlock() l.events = append(l.events, event) } func (l *traceLog) snapshot() []string { l.mu.Lock() defer l.mu.Unlock() return append([]string(nil), l.events...) } type fakeComponent struct { name string trace *traceLog startErr error stopErr error startCtx context.Context startDone chan struct{} blockOnCancel bool stopStarted chan struct{} stopRelease chan struct{} stopOnce sync.Once } func (c *fakeComponent) Name() string { return c.name } func (c *fakeComponent) Start(ctx context.Context) error { c.trace.add("start:" + c.name) c.startCtx = ctx if c.blockOnCancel { close(c.startDone) <-ctx.Done() return ctx.Err() } return c.startErr } func (c *fakeComponent) Stop(context.Context) error { c.trace.add("stop:" + c.name) if c.stopStarted != nil { c.stopOnce.Do(func() { close(c.stopStarted) }) } if c.stopRelease != nil { <-c.stopRelease } return c.stopErr } func TestHostStartStopOrdering(t *testing.T) { trace := &traceLog{} components := []*fakeComponent{ {name: "first", trace: trace}, {name: "second", trace: trace}, {name: "third", trace: trace}, } host := NewHost(components[0], components[1], components[2]) if err := host.Start(context.Background()); err != nil { t.Fatalf("Start() error = %v", err) } status := host.Status() if got, want := status.Started, []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) { t.Fatalf("Status().Started = %v, want %v", got, want) } // Status must not expose the host's mutable slice. status.Started[0] = "mutated" if got := host.Status().Started[0]; got != "first" { t.Fatalf("Status().Started aliases internal state: got %q", got) } if err := host.Stop(context.Background()); err != nil { t.Fatalf("Stop() error = %v", err) } if got, want := trace.snapshot(), []string{ "start:first", "start:second", "start:third", "stop:third", "stop:second", "stop:first", }; !reflect.DeepEqual(got, want) { t.Fatalf("trace = %v, want %v", got, want) } } func TestHostStartRollback(t *testing.T) { trace := &traceLog{} launchErr := errors.New("launch failed") rollbackErr := errors.New("rollback failed") first := &fakeComponent{name: "first", trace: trace, stopErr: rollbackErr} second := &fakeComponent{name: "second", trace: trace, startErr: launchErr} host := NewHost(first, second) err := host.Start(context.Background()) if !errors.Is(err, launchErr) { t.Fatalf("Start() error = %v, want launch error identity", err) } if !errors.Is(err, rollbackErr) { t.Fatalf("Start() error = %v, want rollback error identity", err) } select { case <-first.startCtx.Done(): default: t.Fatal("started component context was not cancelled before rollback") } if got, want := trace.snapshot(), []string{"start:first", "start:second", "stop:first"}; !reflect.DeepEqual(got, want) { t.Fatalf("trace = %v, want %v", got, want) } status := host.Status() if status.Failed != "second" { t.Fatalf("Status().Failed = %q, want second", status.Failed) } if !errors.Is(status.LaunchErr, launchErr) { t.Fatalf("Status().LaunchErr = %v, want launch error identity", status.LaunchErr) } if !errors.Is(status.StopErr, rollbackErr) { t.Fatalf("Status().StopErr = %v, want rollback error identity", status.StopErr) } if !status.Stopped { t.Fatal("Status().Stopped = false after completed rollback") } } func TestHostStopIsIdempotent(t *testing.T) { trace := &traceLog{} stopErr := errors.New("stop failed") component := &fakeComponent{name: "only", trace: trace, stopErr: stopErr} host := NewHost(component) if err := host.Start(context.Background()); err != nil { t.Fatalf("Start() error = %v", err) } if err := host.Stop(context.Background()); !errors.Is(err, stopErr) { t.Fatalf("first Stop() error = %v, want stop error identity", err) } if err := host.Stop(context.Background()); err != nil { t.Fatalf("second Stop() error = %v, want nil", err) } if got, want := trace.snapshot(), []string{"start:only", "stop:only"}; !reflect.DeepEqual(got, want) { t.Fatalf("trace = %v, want %v", got, want) } status := host.Status() if !status.Stopped || !errors.Is(status.StopErr, stopErr) { t.Fatalf("Status() = %+v, want stopped with retained stop error", status) } } func TestHostStopCancelsInProgressStart(t *testing.T) { trace := &traceLog{} stopStarted := make(chan struct{}) stopRelease := make(chan struct{}) var releaseOnce sync.Once releaseStop := func() { releaseOnce.Do(func() { close(stopRelease) }) } defer releaseStop() readyComp := &fakeComponent{ name: "ready", trace: trace, stopStarted: stopStarted, stopRelease: stopRelease, } blockingComp := &fakeComponent{ name: "blocking", trace: trace, startDone: make(chan struct{}), blockOnCancel: true, } host := NewHost(readyComp, blockingComp) startDone := make(chan error, 1) go func() { startDone <- host.Start(context.Background()) }() // Wait for the blocking component to enter Start and signal readiness. select { case <-blockingComp.startDone: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for blocking component to enter Start") } // Concurrently call Stop while Start is blocked in the blocking component. var wg sync.WaitGroup wg.Add(1) var stopErr error go func() { defer wg.Done() stopErr = host.Stop(context.Background()) }() // Assert that Status().Stopped remains false while rollback cleanup is running. select { case <-stopStarted: case <-time.After(2 * time.Second): t.Fatal("timeout waiting for ready component rollback cleanup to start") } if status := host.Status(); status.Stopped { t.Fatal("Status().Stopped = true before cleanup completed") } releaseStop() // Wait for both goroutines to complete within a bounded deadline. done := make(chan struct{}) go func() { wg.Wait() close(done) }() select { case <-done: case <-time.After(5 * time.Second): t.Fatal("timeout waiting for Stop and Start to complete") } // Stop must return nil. if stopErr != nil { t.Fatalf("Stop() error = %v, want nil", stopErr) } // Start must return an error (context.Canceled propagates). startErr := <-startDone if startErr == nil { t.Fatal("Start() returned nil, want context.Canceled error") } if !errors.Is(startErr, context.Canceled) { t.Fatalf("Start() error = %v, want context.Canceled", startErr) } // The blocking component must NOT have been stopped (its Start did not succeed). if got := trace.snapshot(); !reflect.DeepEqual(got, []string{ "start:ready", "start:blocking", "stop:ready", }) { t.Fatalf("unexpected trace: %v", got) } // Status must be terminal. status := host.Status() if !status.Stopped { t.Fatal("Status().Stopped = false, want true") } if !errors.Is(status.LaunchErr, context.Canceled) { t.Fatalf("Status().LaunchErr = %v, want context.Canceled", status.LaunchErr) } // Repeated Stop must return nil. if err := host.Stop(context.Background()); err != nil { t.Fatalf("second Stop() error = %v, want nil", err) } }