| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "reflect" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | ) |
| 12 | |
| 13 | func TestPublishedRuntimeStateDoesNotWaitForSamplingLocks(t *testing.T) { |
| 14 | isolateControlConfigHome(t) |
| 15 | c := newOwnedTestController(t, Options{SessionDir: t.TempDir()}) |
| 16 | defer c.Close() |
| 17 | want := c.RuntimeStateSnapshot() |
| 18 | if want.SchemaVersion != 1 || want.Revision == 0 { |
| 19 | t.Fatalf("initial state was not committed: %+v", want) |
| 20 | } |
| 21 | // A producer waiting on a controller/session owner holds the sampling |
| 22 | // mutex. Neither that mutex nor the controller lock may gate UI reads. |
| 23 | c.runtimeState.mu.Lock() |
| 24 | defer c.runtimeState.mu.Unlock() |
| 25 | c.mu.Lock() |
| 26 | defer c.mu.Unlock() |
| 27 | done := make(chan event.RuntimeStateSnapshot, 1) |
| 28 | go func() { done <- c.PublishedRuntimeStateSnapshot() }() |
| 29 | select { |
| 30 | case got := <-done: |
| 31 | if !reflect.DeepEqual(got, want) { |
| 32 | t.Fatalf("committed state changed while producer is blocked: got=%+v want=%+v", got, want) |
| 33 | } |
| 34 | case <-time.After(5 * time.Second): |
| 35 | t.Fatal("published read waited on an owner or sampling lock") |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestPublishedRuntimeStateTracksCancellationAndCompletion(t *testing.T) { |
| 40 | isolateControlConfigHome(t) |
| 41 | sink := &runtimeStateTestSink{Sink: event.FuncSink(func(event.Event) {}), states: make(chan event.RuntimeStateSnapshot, 64)} |
| 42 | c := newOwnedTestController(t, Options{SessionDir: t.TempDir(), Sink: sink}) |
| 43 | defer c.Close() |
| 44 | release := make(chan struct{}) |
| 45 | releaseBody := sync.OnceFunc(func() { close(release) }) |
| 46 | defer releaseBody() |
| 47 | started := c.PublishedRuntimeStateSnapshot() |
| 48 | c.runGuarded(func(ctx context.Context) error { <-release; return ctx.Err() }) |
| 49 | for _, phase := range []string{"executing", "cancelling", "idle"} { |
| 50 | switch phase { |
| 51 | case "cancelling": |
| 52 | c.CancelSession() |
| 53 | case "idle": |
| 54 | releaseBody() |
| 55 | } |
| 56 | published := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == phase }) |
| 57 | got := c.PublishedRuntimeStateSnapshot() |
| 58 | if got.Phase != phase || got.Revision < published.Revision || got.RuntimeEpoch != started.RuntimeEpoch { |
| 59 | t.Fatalf("committed state did not follow %s: %+v", phase, got) |
| 60 | } |
| 61 | if phase == "idle" && got.ActiveWork() { |
| 62 | t.Fatalf("completed turn still appears active: %+v", got) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestPublishedRuntimeStateOwnsMutableObservations(t *testing.T) { |
| 68 | c := &Controller{} |
| 69 | state := event.RuntimeStateSnapshot{ |
| 70 | Revision: 1, Todos: []event.Todo{{Content: "original"}}, |
| 71 | Interactions: []event.PendingInteraction{{RequestID: "original"}}, |
| 72 | Recovery: &event.RecoveryStatus{Reason: "original"}, |
| 73 | Maintenance: &event.MaintenanceState{OperationID: "original"}, |
| 74 | } |
| 75 | c.runtimeState.commitSnapshot(state) |
| 76 | // Neither producer-owned input nor a UI consumer may rewrite a version |
| 77 | // another reader has already observed. |
| 78 | state.Todos[0].Content = "producer mutation" |
| 79 | first := c.PublishedRuntimeStateSnapshot() |
| 80 | if first.Todos[0].Content != "original" { |
| 81 | t.Fatal("producer retained ownership of committed todos") |
| 82 | } |
| 83 | first.Todos[0].Content = "consumer mutation" |
| 84 | first.Interactions[0].RequestID = "consumer mutation" |
| 85 | first.Recovery.Reason = "consumer mutation" |
| 86 | first.Maintenance.OperationID = "consumer mutation" |
| 87 | second := c.PublishedRuntimeStateSnapshot() |
| 88 | if second.Todos[0].Content != "original" || second.Interactions[0].RequestID != "original" || |
| 89 | second.Recovery.Reason != "original" || second.Maintenance.OperationID != "original" { |
| 90 | t.Fatalf("a reader mutated the committed version: %+v", second) |
| 91 | } |
| 92 | } |
| 93 |