Skip to content

Commit a1fd907

Browse files
authored
Merge pull request #15 from CodeThicket/feat/expose-leader-status
feat(core): expose leader role/term/leaderTabId (unblocks failover e2e #28-#31)
2 parents e7b4d2b + 87173f2 commit a1fd907

5 files changed

Lines changed: 120 additions & 7 deletions

File tree

e2e/multi-tab.spec.ts

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,11 +305,76 @@ test.describe('TabMesh — multi-tab harness', () => {
305305
// Plus a delivery URL endpoint in the test fixture.
306306
});
307307

308-
test.fixme('Elected-leader failover within 50ms (#28-#31)', async () => {
309-
// Force fallback by deleting `window.SharedWorker` before mesh.start().
310-
// Then open 3 tabs, identify the leader (term + tabId), close it, and
311-
// verify another tab claims leadership inside the Web Locks SLA.
312-
// Requires either a debug system event from the leader or a
313-
// `mesh.getStatus()` field exposing the current term/leaderTabId.
308+
test('elected-leader failover when the leader tab closes (#28-#31)', async ({ context }) => {
309+
// Force fallback mode via `?hub=elected` (deletes window.SharedWorker
310+
// before mesh.start). Open 3 tabs, identify the leader, close it, and
311+
// verify a different tab takes over with a higher term.
312+
const a = await newPlaygroundTab(context, { hub: 'elected' });
313+
const b = await newPlaygroundTab(context, { hub: 'elected' });
314+
const c = await newPlaygroundTab(context, { hub: 'elected' });
315+
316+
type Status = {
317+
role: 'hub' | 'follower' | null;
318+
tabId: string;
319+
leaderTabId: string | null;
320+
term: number;
321+
};
322+
const readStatus = (page: Page): Promise<Status> =>
323+
page.evaluate(() => {
324+
const m = (globalThis as unknown as { __tabmesh: { getStatus(): Status } }).__tabmesh;
325+
return m.getStatus();
326+
});
327+
const allReady = async (): Promise<Status[]> => {
328+
const statuses = await Promise.all([a, b, c].map(readStatus));
329+
return statuses;
330+
};
331+
332+
// Wait for exactly one leader to be elected across the three tabs.
333+
await expect
334+
.poll(
335+
async () => {
336+
const statuses = await allReady();
337+
return statuses.filter((s) => s.role === 'hub').length;
338+
},
339+
{ timeout: 10_000 }
340+
)
341+
.toBe(1);
342+
343+
const initial = await allReady();
344+
const leader = initial.find((s) => s.role === 'hub');
345+
expect(leader).toBeDefined();
346+
if (!leader) throw new Error('unreachable');
347+
const leaderTabId = leader.tabId;
348+
349+
const leaderPage = [a, b, c].find((page, i) => initial[i] && initial[i]?.tabId === leaderTabId);
350+
if (!leaderPage) throw new Error('could not match leader page');
351+
352+
await leaderPage.close();
353+
const survivors = [a, b, c].filter((p) => !p.isClosed());
354+
expect(survivors.length).toBe(2);
355+
356+
// The remaining tabs must elect a new leader. Web Locks failover is
357+
// sub-50ms in spec; BC heartbeat fallback is ~1.5s; IDB is up to 5s.
358+
// Headless Chromium supports Web Locks, but we keep a generous bound.
359+
//
360+
// Note: in Web Locks mode the per-tab `term` counter doesn't carry
361+
// across tabs (each tab tracks its own term-of-this-leadership), so
362+
// the meaningful assertion is "a different tab is now the leader."
363+
await expect
364+
.poll(
365+
async () => {
366+
const updated = await Promise.all(survivors.map(readStatus));
367+
const newLeader = updated.find((s) => s.role === 'hub');
368+
if (!newLeader) return null;
369+
if (newLeader.tabId === leaderTabId) return null;
370+
return newLeader.tabId;
371+
},
372+
{ timeout: 10_000 }
373+
)
374+
.not.toBeNull();
375+
376+
for (const page of survivors) {
377+
await page.close();
378+
}
314379
});
315380
});

packages/core/src/TabMesh.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,31 @@ export class TabMesh {
375375
* Get the current status of the mesh.
376376
*/
377377
getStatus(): TabMeshStatus {
378+
let role: TabMeshStatus['role'] = null;
379+
let leaderTabId: string | null = null;
380+
let term = 0;
381+
if (this.hubMode === 'shared-worker') {
382+
// In shared-worker mode every tab is a follower of the worker. There
383+
// is no "leader" concept — the worker isn't a tab.
384+
role = 'follower';
385+
} else if (this.hub instanceof ElectedLeaderHub) {
386+
const snap = this.hub.getElectionSnapshot();
387+
if (snap) {
388+
role = snap.isLeader ? 'hub' : 'follower';
389+
leaderTabId = snap.leaderTabId;
390+
term = snap.term;
391+
}
392+
}
378393
return {
379394
started: this.started,
380395
hubMode: this.hubMode,
381396
hubConnected: this.hub?.connected ?? false,
382-
role: this.hubMode === 'shared-worker' ? 'follower' : null,
397+
role,
383398
transportState: this.transportState,
384399
tabId: this.tabId,
385400
degraded: this.degraded,
401+
leaderTabId,
402+
term,
386403
};
387404
}
388405

packages/core/src/hub/ElectedLeaderHub.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ export class ElectedLeaderHub implements Hub {
6565
return this._connected;
6666
}
6767

68+
/**
69+
* Snapshot of the current leader-election state. Returns `null` if the
70+
* hub hasn't started electing yet. Used by TabMesh.getStatus().
71+
*/
72+
getElectionSnapshot(): { isLeader: boolean; leaderTabId: string | null; term: number } | null {
73+
if (!this.leader) return null;
74+
return {
75+
isLeader: this.leader.leader,
76+
leaderTabId: this.leader.leaderTabId,
77+
term: this.leader.term,
78+
};
79+
}
80+
6881
async connect(tabId: string): Promise<void> {
6982
this.tabId = tabId;
7083

packages/core/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,18 @@ export interface TabMeshStatus {
393393
tabId: string;
394394
/** Whether persistence is degraded (in-memory fallback). */
395395
degraded: boolean;
396+
/**
397+
* Current elected-leader's tab id, when running in elected-leader mode.
398+
* Null in shared-worker mode (no leader concept) or before the first
399+
* leader is elected.
400+
*/
401+
leaderTabId?: string | null;
402+
/**
403+
* Current election term, when running in elected-leader mode. Increments
404+
* with each leadership transition. Useful for detecting failover and
405+
* resolving split-brain.
406+
*/
407+
term?: number;
396408
}
397409

398410
// ---------------------------------------------------------------------------

packages/playground/src/mesh.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ function numberFromParam(name: string): number | undefined {
3434
const parsed = Number(raw);
3535
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3636
}
37+
38+
// Expose the mesh instance on `window` for the e2e harness. The playground
39+
// is a demo app — there's no privacy boundary to protect — and the
40+
// alternative (rendering every diagnostic field on screen) bloats the UI
41+
// for tests no human would read.
42+
(globalThis as unknown as { __tabmesh: typeof mesh }).__tabmesh = mesh;

0 commit comments

Comments
 (0)