| 1 | package plugin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | func TestRegistrationScopeRollbackIsInstanceScoped(t *testing.T) { |
| 10 | host := NewHost() |
| 11 | // Pre-existing sibling client accepted under scope A. |
| 12 | siblingA := &Client{name: "sibling"} |
| 13 | scopeA := host.BeginRegistrationScope() |
| 14 | ctxA := ContextWithRegistrationScope(context.Background(), scopeA) |
| 15 | if err := host.ReplaceServerBackend(ctxA, "sibling", siblingA, 1); err != nil { |
| 16 | t.Fatal(err) |
| 17 | } |
| 18 | refsA := scopeA.Snapshot() |
| 19 | if len(refsA) != 1 || refsA[0].Name != "sibling" || refsA[0].ID == 0 { |
| 20 | t.Fatalf("scope A = %+v", refsA) |
| 21 | } |
| 22 | idA := refsA[0].ID |
| 23 | |
| 24 | // Stale build journals a replacement for the same name plus a new server. |
| 25 | staleSibling := &Client{name: "sibling"} |
| 26 | staleNew := &Client{name: "stale-new"} |
| 27 | scopeStale := host.BeginRegistrationScope() |
| 28 | ctxStale := ContextWithRegistrationScope(context.Background(), scopeStale) |
| 29 | if err := host.ReplaceServerBackend(ctxStale, "sibling", staleSibling, 2); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if err := host.ReplaceServerBackend(ctxStale, "stale-new", staleNew, 2); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | refsStale := scopeStale.Snapshot() |
| 36 | if len(refsStale) != 2 { |
| 37 | t.Fatalf("stale scope len = %d, want 2: %+v", len(refsStale), refsStale) |
| 38 | } |
| 39 | var staleSiblingID uint64 |
| 40 | for _, ref := range refsStale { |
| 41 | if ref.Name == "sibling" { |
| 42 | staleSiblingID = ref.ID |
| 43 | } |
| 44 | } |
| 45 | if staleSiblingID == 0 || staleSiblingID == idA { |
| 46 | t.Fatalf("stale sibling id = %d, original = %d", staleSiblingID, idA) |
| 47 | } |
| 48 | |
| 49 | // Sibling/new-generation resource created AFTER the stale scope must survive |
| 50 | // rollback (name-diff would delete it incorrectly). No scope token. |
| 51 | siblingNewGen := &Client{name: "sibling-new-gen"} |
| 52 | if err := host.ReplaceServerBackend(context.Background(), "sibling-new-gen", siblingNewGen, 3); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | // Newer generation also replaces the shared name after the stale scope ends. |
| 56 | newerSibling := &Client{name: "sibling"} |
| 57 | if err := host.ReplaceServerBackend(context.Background(), "sibling", newerSibling, 4); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | newerID := newerSibling.instanceID |
| 61 | if newerID == 0 || newerID == staleSiblingID { |
| 62 | t.Fatalf("newer sibling id = %d, stale = %d", newerID, staleSiblingID) |
| 63 | } |
| 64 | |
| 65 | scopeStale.AbortAndRollback() |
| 66 | |
| 67 | names := map[string]bool{} |
| 68 | for _, name := range host.ServerNames() { |
| 69 | names[name] = true |
| 70 | } |
| 71 | if names["stale-new"] { |
| 72 | t.Fatal("stale-new survived instance-scoped rollback") |
| 73 | } |
| 74 | if !names["sibling-new-gen"] { |
| 75 | t.Fatal("sibling-new-gen was incorrectly removed by stale scope rollback") |
| 76 | } |
| 77 | if !names["sibling"] { |
| 78 | t.Fatal("newer-generation sibling instance was incorrectly removed") |
| 79 | } |
| 80 | if host.lookupClient("sibling") == nil || host.lookupClient("sibling").instanceID != newerID { |
| 81 | t.Fatalf("sibling live instance = %+v, want id %d", host.lookupClient("sibling"), newerID) |
| 82 | } |
| 83 | if host.RemoveIfInstance("sibling", idA) { |
| 84 | t.Fatal("RemoveIfInstance removed a non-matching/missing instance") |
| 85 | } |
| 86 | if host.RemoveIfInstance("sibling", staleSiblingID) { |
| 87 | t.Fatal("RemoveIfInstance removed the newer instance via stale id") |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // TestRegistrationScopeIgnoresUnrelatedHostWrites is the deterministic |
| 92 | // interleaving that Host-global regJournalActive fails: while a stale build |
| 93 | // holds an open scope, a sibling hot-add without the scope token must not be |
| 94 | // attributed to the stale build or deleted by its rollback. |
| 95 | func TestRegistrationScopeIgnoresUnrelatedHostWrites(t *testing.T) { |
| 96 | host := NewHost() |
| 97 | scopeStale := host.BeginRegistrationScope() |
| 98 | ctxStale := ContextWithRegistrationScope(context.Background(), scopeStale) |
| 99 | |
| 100 | // Sibling hot-add during the open scope WITHOUT the token. |
| 101 | sibling := &Client{name: "sibling-hot-add"} |
| 102 | if err := host.ReplaceServerBackend(context.Background(), "sibling-hot-add", sibling, 1); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | // Stale build also registers its own server under the scope. |
| 106 | staleOnly := &Client{name: "stale-only"} |
| 107 | if err := host.ReplaceServerBackend(ctxStale, "stale-only", staleOnly, 1); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | |
| 111 | refs := scopeStale.Snapshot() |
| 112 | for _, ref := range refs { |
| 113 | if ref.Name == "sibling-hot-add" { |
| 114 | t.Fatalf("journal captured unrelated Host write: %+v", refs) |
| 115 | } |
| 116 | } |
| 117 | if len(refs) != 1 || refs[0].Name != "stale-only" { |
| 118 | t.Fatalf("stale scope refs = %+v, want only stale-only", refs) |
| 119 | } |
| 120 | |
| 121 | scopeStale.AbortAndRollback() |
| 122 | |
| 123 | names := map[string]bool{} |
| 124 | for _, name := range host.ServerNames() { |
| 125 | names[name] = true |
| 126 | } |
| 127 | if names["stale-only"] { |
| 128 | t.Fatal("stale-only survived AbortAndRollback") |
| 129 | } |
| 130 | if !names["sibling-hot-add"] { |
| 131 | t.Fatal("sibling-hot-add was deleted by stale scope rollback") |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func TestRegistrationScopeRejectsLateRegistrationAfterAbort(t *testing.T) { |
| 136 | host := NewHost() |
| 137 | scope := host.BeginRegistrationScope() |
| 138 | ctx := ContextWithRegistrationScope(context.Background(), scope) |
| 139 | scope.AbortAndRollback() |
| 140 | |
| 141 | late := &Client{name: "late"} |
| 142 | err := host.ReplaceServerBackend(ctx, "late", late, 1) |
| 143 | if !errors.Is(err, ErrRegistrationScopeAborted) { |
| 144 | t.Fatalf("late registration err = %v, want ErrRegistrationScopeAborted", err) |
| 145 | } |
| 146 | for _, name := range host.ServerNames() { |
| 147 | if name == "late" { |
| 148 | t.Fatal("aborted scope accepted a late client") |
| 149 | } |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestRegistrationScopeCommitPreservesClientReusedByNewerBuild(t *testing.T) { |
| 154 | host := NewHost() |
| 155 | older := host.BeginRegistrationScope() |
| 156 | olderCtx := ContextWithRegistrationScope(context.Background(), older) |
| 157 | client := &Client{name: "shared", toolCatalog: toolCatalogSnapshot{listed: true}} |
| 158 | if err := host.ReplaceServerBackend(olderCtx, "shared", client, 1); err != nil { |
| 159 | t.Fatal(err) |
| 160 | } |
| 161 | |
| 162 | newer := host.BeginRegistrationScope() |
| 163 | newerCtx := ContextWithRegistrationScope(context.Background(), newer) |
| 164 | if _, err := host.ToolsFor(newerCtx, "shared"); err != nil { |
| 165 | t.Fatalf("newer build failed to reuse shared client: %v", err) |
| 166 | } |
| 167 | if !newer.Commit() { |
| 168 | t.Fatal("newer build could not commit its shared-client claim") |
| 169 | } |
| 170 | |
| 171 | older.AbortAndRollback() |
| 172 | if got := host.lookupClient("shared"); got != client { |
| 173 | t.Fatalf("older rollback removed client committed by newer build: got=%p want=%p", got, client) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestRegistrationScopeActiveClaimDefersCreatorRollback(t *testing.T) { |
| 178 | host := NewHost() |
| 179 | creator := host.BeginRegistrationScope() |
| 180 | creatorCtx := ContextWithRegistrationScope(context.Background(), creator) |
| 181 | client := &Client{name: "shared", toolCatalog: toolCatalogSnapshot{listed: true}} |
| 182 | if err := host.ReplaceServerBackend(creatorCtx, "shared", client, 1); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | |
| 186 | consumer := host.BeginRegistrationScope() |
| 187 | consumerCtx := ContextWithRegistrationScope(context.Background(), consumer) |
| 188 | if _, err := host.ToolsFor(consumerCtx, "shared"); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | creator.AbortAndRollback() |
| 192 | if !host.HasClient("shared") { |
| 193 | t.Fatal("creator rollback removed a client claimed by an active consumer") |
| 194 | } |
| 195 | |
| 196 | consumer.AbortAndRollback() |
| 197 | if host.HasClient("shared") { |
| 198 | t.Fatal("client survived after every uncommitted scope aborted") |
| 199 | } |
| 200 | if got := host.lookupClient("shared"); got != nil { |
| 201 | t.Fatalf("proxy still exposed rolled-back client: %+v", got) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestCommittedScopeAcceptsLateLazyRegistration(t *testing.T) { |
| 206 | host := NewHost() |
| 207 | scope := host.BeginRegistrationScope() |
| 208 | ctx := ContextWithRegistrationScope(context.Background(), scope) |
| 209 | if !scope.Commit() { |
| 210 | t.Fatal("commit failed") |
| 211 | } |
| 212 | |
| 213 | late := &Client{name: "late"} |
| 214 | if err := host.ReplaceServerBackend(ctx, "late", late, 1); err != nil { |
| 215 | t.Fatalf("published scope rejected a late lazy registration: %v", err) |
| 216 | } |
| 217 | scope.AbortAndRollback() |
| 218 | if got := host.lookupClient("late"); got != late { |
| 219 | t.Fatal("committed scope rollback removed its late lazy registration") |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestRegistrationScopesRemainIndependentWhileBothActive(t *testing.T) { |
| 224 | host := NewHost() |
| 225 | first := host.BeginRegistrationScope() |
| 226 | second := host.BeginRegistrationScope() |
| 227 | firstCtx := ContextWithRegistrationScope(context.Background(), first) |
| 228 | secondCtx := ContextWithRegistrationScope(context.Background(), second) |
| 229 | if err := host.ReplaceServerBackend(firstCtx, "a", &Client{name: "a"}, 1); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | if err := host.ReplaceServerBackend(secondCtx, "b", &Client{name: "b"}, 1); err != nil { |
| 233 | t.Fatal(err) |
| 234 | } |
| 235 | if len(first.Snapshot()) != 1 || len(second.Snapshot()) != 1 { |
| 236 | t.Fatalf("active scope claims leaked: first=%+v second=%+v", first.Snapshot(), second.Snapshot()) |
| 237 | } |
| 238 | } |
| 239 |