Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 71 additions & 6 deletions e2e/multi-tab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
await page.click('form.todo-form button[type="submit"]');
}

test.describe('TabMesh — multi-tab harness', () => {

Check failure on line 56 in e2e/multi-tab.spec.ts

View workflow job for this annotation

GitHub Actions / Test

e2e/multi-tab.spec.ts

Error: Playwright Test did not expect test.describe() to be called here. Most common reasons include: - You are calling test.describe() in a configuration file. - You are calling test.describe() in a file that is imported by the configuration file. - You have two different versions of @playwright/test. This usually happens when one of the dependencies in your package.json depends on @playwright/test. ❯ TestTypeImpl._currentSuite node_modules/.pnpm/playwright@1.59.1/node_modules/playwright/lib/common/testType.js:75:13 ❯ TestTypeImpl._describe node_modules/.pnpm/playwright@1.59.1/node_modules/playwright/lib/common/testType.js:115:24 ❯ Function.describe node_modules/.pnpm/playwright@1.59.1/node_modules/playwright/lib/transform/transform.js:275:12 ❯ e2e/multi-tab.spec.ts:56:6
test.describe.configure({ mode: 'serial' });

test('single WS connection across two tabs (#1)', async ({ context }) => {
Expand Down Expand Up @@ -305,11 +305,76 @@
// Plus a delivery URL endpoint in the test fixture.
});

test.fixme('Elected-leader failover within 50ms (#28-#31)', async () => {
// Force fallback by deleting `window.SharedWorker` before mesh.start().
// Then open 3 tabs, identify the leader (term + tabId), close it, and
// verify another tab claims leadership inside the Web Locks SLA.
// Requires either a debug system event from the leader or a
// `mesh.getStatus()` field exposing the current term/leaderTabId.
test('elected-leader failover when the leader tab closes (#28-#31)', async ({ context }) => {
// Force fallback mode via `?hub=elected` (deletes window.SharedWorker
// before mesh.start). Open 3 tabs, identify the leader, close it, and
// verify a different tab takes over with a higher term.
const a = await newPlaygroundTab(context, { hub: 'elected' });
const b = await newPlaygroundTab(context, { hub: 'elected' });
const c = await newPlaygroundTab(context, { hub: 'elected' });

type Status = {
role: 'hub' | 'follower' | null;
tabId: string;
leaderTabId: string | null;
term: number;
};
const readStatus = (page: Page): Promise<Status> =>
page.evaluate(() => {
const m = (globalThis as unknown as { __tabmesh: { getStatus(): Status } }).__tabmesh;
return m.getStatus();
});
const allReady = async (): Promise<Status[]> => {
const statuses = await Promise.all([a, b, c].map(readStatus));
return statuses;
};

// Wait for exactly one leader to be elected across the three tabs.
await expect
.poll(
async () => {
const statuses = await allReady();
return statuses.filter((s) => s.role === 'hub').length;
},
{ timeout: 10_000 }
)
.toBe(1);

const initial = await allReady();
const leader = initial.find((s) => s.role === 'hub');
expect(leader).toBeDefined();
if (!leader) throw new Error('unreachable');
const leaderTabId = leader.tabId;

const leaderPage = [a, b, c].find((page, i) => initial[i] && initial[i]?.tabId === leaderTabId);
if (!leaderPage) throw new Error('could not match leader page');

await leaderPage.close();
const survivors = [a, b, c].filter((p) => !p.isClosed());
expect(survivors.length).toBe(2);

// The remaining tabs must elect a new leader. Web Locks failover is
// sub-50ms in spec; BC heartbeat fallback is ~1.5s; IDB is up to 5s.
// Headless Chromium supports Web Locks, but we keep a generous bound.
//
// Note: in Web Locks mode the per-tab `term` counter doesn't carry
// across tabs (each tab tracks its own term-of-this-leadership), so
// the meaningful assertion is "a different tab is now the leader."
await expect
.poll(
async () => {
const updated = await Promise.all(survivors.map(readStatus));
const newLeader = updated.find((s) => s.role === 'hub');
if (!newLeader) return null;
if (newLeader.tabId === leaderTabId) return null;
return newLeader.tabId;
},
{ timeout: 10_000 }
)
.not.toBeNull();

for (const page of survivors) {
await page.close();
}
});
});
19 changes: 18 additions & 1 deletion packages/core/src/TabMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,14 +375,31 @@ export class TabMesh {
* Get the current status of the mesh.
*/
getStatus(): TabMeshStatus {
let role: TabMeshStatus['role'] = null;
let leaderTabId: string | null = null;
let term = 0;
if (this.hubMode === 'shared-worker') {
// In shared-worker mode every tab is a follower of the worker. There
// is no "leader" concept — the worker isn't a tab.
role = 'follower';
} else if (this.hub instanceof ElectedLeaderHub) {
const snap = this.hub.getElectionSnapshot();
if (snap) {
role = snap.isLeader ? 'hub' : 'follower';
leaderTabId = snap.leaderTabId;
term = snap.term;
}
}
return {
started: this.started,
hubMode: this.hubMode,
hubConnected: this.hub?.connected ?? false,
role: this.hubMode === 'shared-worker' ? 'follower' : null,
role,
transportState: this.transportState,
tabId: this.tabId,
degraded: this.degraded,
leaderTabId,
term,
};
}

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/hub/ElectedLeaderHub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ export class ElectedLeaderHub implements Hub {
return this._connected;
}

/**
* Snapshot of the current leader-election state. Returns `null` if the
* hub hasn't started electing yet. Used by TabMesh.getStatus().
*/
getElectionSnapshot(): { isLeader: boolean; leaderTabId: string | null; term: number } | null {
if (!this.leader) return null;
return {
isLeader: this.leader.leader,
leaderTabId: this.leader.leaderTabId,
term: this.leader.term,
};
}

async connect(tabId: string): Promise<void> {
this.tabId = tabId;

Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,18 @@ export interface TabMeshStatus {
tabId: string;
/** Whether persistence is degraded (in-memory fallback). */
degraded: boolean;
/**
* Current elected-leader's tab id, when running in elected-leader mode.
* Null in shared-worker mode (no leader concept) or before the first
* leader is elected.
*/
leaderTabId?: string | null;
/**
* Current election term, when running in elected-leader mode. Increments
* with each leadership transition. Useful for detecting failover and
* resolving split-brain.
*/
term?: number;
}

// ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions packages/playground/src/mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ function numberFromParam(name: string): number | undefined {
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}

// Expose the mesh instance on `window` for the e2e harness. The playground
// is a demo app — there's no privacy boundary to protect — and the
// alternative (rendering every diagnostic field on screen) bloats the UI
// for tests no human would read.
(globalThis as unknown as { __tabmesh: typeof mesh }).__tabmesh = mesh;
Loading