| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/http/httptest" |
| 6 | "sync/atomic" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // identityOwnershipServe answers /ownership for an identity-routed session, |
| 11 | // records the selector each probe asked about, and runs duringProbe while the |
| 12 | // answer is still in flight. |
| 13 | func identityOwnershipServe(t *testing.T, holder string, asked *atomic.Value, duringProbe func()) *httptest.Server { |
| 14 | t.Helper() |
| 15 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 16 | if r.URL.Path != "/ownership" { |
| 17 | w.WriteHeader(http.StatusNotFound) |
| 18 | return |
| 19 | } |
| 20 | asked.Store(r.URL.Query().Get("session")) |
| 21 | if duringProbe != nil { |
| 22 | duringProbe() |
| 23 | } |
| 24 | w.Header().Set("Content-Type", "application/json") |
| 25 | _, _ = w.Write([]byte(`{"available":true,"holder":"` + holder + `","sessionPath":"session-id:held"}`)) |
| 26 | })) |
| 27 | t.Cleanup(srv.Close) |
| 28 | return srv |
| 29 | } |
| 30 | |
| 31 | func identityNoticeTab(srv *httptest.Server) (*App, *remoteTab) { |
| 32 | tab := &remoteTab{ |
| 33 | id: "remote-1", state: "ready", gen: 4, client: srv.Client(), base: srv.URL, |
| 34 | routing: remoteTabSessionRouting{currentPath: "session-id:held", running: map[string]bool{}}, |
| 35 | session: remoteTabSessionState{sessionID: "held"}, |
| 36 | } |
| 37 | return &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}, tab |
| 38 | } |
| 39 | |
| 40 | // Serve now delivers takeover and reclaim notices on identity routes, which it |
| 41 | // previously dropped for this subscriber. The tab must route such a notice to |
| 42 | // the ownership probe under its identity route and adopt the answer. |
| 43 | func TestIdentityRoutedTakeoverNoticeProbesAndPinsSpectator(t *testing.T) { |
| 44 | var asked atomic.Value |
| 45 | srv := identityOwnershipServe(t, "external", &asked, nil) |
| 46 | a, tab := identityNoticeTab(srv) |
| 47 | |
| 48 | a.probeSpectatorAfterNotice(tab.id, tab.gen, tab.client, tab.base, "session-id:held") |
| 49 | |
| 50 | if got, _ := asked.Load().(string); got != "session-id:held" { |
| 51 | t.Fatalf("ownership probe asked about %q, want the identity route", got) |
| 52 | } |
| 53 | a.remoteTabMu.Lock() |
| 54 | takenOver := tab.session.takenOver |
| 55 | a.remoteTabMu.Unlock() |
| 56 | if !takenOver { |
| 57 | t.Fatal("an identity-routed takeover notice did not pin the spectator state") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // A reclaimed notice for a session that is no longer locally owned clears the |
| 62 | // pin, so ownership returning through a notice does not leave a stale banner. |
| 63 | func TestIdentityRoutedReclaimedNoticeClearsSpectator(t *testing.T) { |
| 64 | var asked atomic.Value |
| 65 | srv := identityOwnershipServe(t, "free", &asked, nil) |
| 66 | a, tab := identityNoticeTab(srv) |
| 67 | tab.session.takenOver = true |
| 68 | |
| 69 | a.probeSpectatorAfterNotice(tab.id, tab.gen, tab.client, tab.base, "session-id:held") |
| 70 | |
| 71 | a.remoteTabMu.Lock() |
| 72 | takenOver := tab.session.takenOver |
| 73 | a.remoteTabMu.Unlock() |
| 74 | if takenOver { |
| 75 | t.Fatal("a reclaimed notice left the spectator banner pinned") |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | // A notice for a different session must not probe or re-pin this tab. |
| 80 | func TestIdentityRoutedNoticeForAnotherSessionIsIgnored(t *testing.T) { |
| 81 | var asked atomic.Value |
| 82 | srv := identityOwnershipServe(t, "external", &asked, nil) |
| 83 | a, tab := identityNoticeTab(srv) |
| 84 | |
| 85 | a.probeSpectatorAfterNotice(tab.id, tab.gen, tab.client, tab.base, "session-id:other") |
| 86 | |
| 87 | if asked.Load() != nil { |
| 88 | t.Fatal("a notice for another session probed ownership for this tab") |
| 89 | } |
| 90 | a.remoteTabMu.Lock() |
| 91 | takenOver := tab.session.takenOver |
| 92 | a.remoteTabMu.Unlock() |
| 93 | if takenOver { |
| 94 | t.Fatal("a notice for another session pinned the spectator banner") |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // Now that these notices arrive on identity routes, one can land around an |
| 99 | // explicit take-back. A probe whose answer predates the reclaim must not |
| 100 | // re-pin the banner after ownership returned — the fence the reclaim epoch |
| 101 | // already applies to in-flight status payloads. |
| 102 | func TestOwnershipProbeCannotRepinAcrossACompletedReclaim(t *testing.T) { |
| 103 | var asked atomic.Value |
| 104 | var a *App |
| 105 | var tab *remoteTab |
| 106 | // The reclaim completes while the stale "still externally held" answer is |
| 107 | // in flight, so the ordering needs no timing assumptions. |
| 108 | srv := identityOwnershipServe(t, "external", &asked, func() { |
| 109 | a.remoteTabMu.Lock() |
| 110 | tab.session.takenOver = false |
| 111 | tab.ownership.reclaimRevision = tab.runtime.revision + 1 |
| 112 | a.remoteTabMu.Unlock() |
| 113 | }) |
| 114 | a, tab = identityNoticeTab(srv) |
| 115 | tab.session.takenOver = true |
| 116 | |
| 117 | a.probeSpectatorAfterNotice(tab.id, tab.gen, tab.client, tab.base, "session-id:held") |
| 118 | |
| 119 | a.remoteTabMu.Lock() |
| 120 | takenOver := tab.session.takenOver |
| 121 | a.remoteTabMu.Unlock() |
| 122 | if takenOver { |
| 123 | t.Fatal("a pre-reclaim ownership probe re-pinned the spectator banner") |
| 124 | } |
| 125 | } |
| 126 |