@spooky-sync/core 0.0.1-canary.164 → 0.0.1-canary.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +27 -20
- package/dist/tabs-broker-worker.js +5 -5
- package/package.json +3 -3
- package/src/services/tabs/coordinator.test.ts +55 -3
- package/src/services/tabs/coordinator.ts +9 -0
- package/src/services/tabs/fake-ports.fixture.ts +42 -0
- package/src/services/tabs/protocol.ts +7 -4
- package/src/services/tabs/tabs-broker-worker.ts +12 -7
- package/src/sp00ky.ts +7 -0
package/dist/index.js
CHANGED
|
@@ -6543,8 +6543,8 @@ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
|
|
|
6543
6543
|
|
|
6544
6544
|
//#endregion
|
|
6545
6545
|
//#region src/modules/devtools/index.ts
|
|
6546
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
6547
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
6546
|
+
const CORE_VERSION = "0.0.1-canary.167";
|
|
6547
|
+
const WASM_VERSION = "0.0.1-canary.167";
|
|
6548
6548
|
const SURREAL_VERSION = "3.0.3";
|
|
6549
6549
|
var DevToolsService = class DevToolsService {
|
|
6550
6550
|
eventsHistory = [];
|
|
@@ -9536,6 +9536,9 @@ var TabsCoordinator = class {
|
|
|
9536
9536
|
}, "Promotion failed");
|
|
9537
9537
|
this.hub?.detachAll();
|
|
9538
9538
|
this.hub = null;
|
|
9539
|
+
if (previousRole === "leader") await this.deps.hooks.releaseOwnership();
|
|
9540
|
+
this.tabLock?.release();
|
|
9541
|
+
this.tabLock = null;
|
|
9539
9542
|
this.broker.send({
|
|
9540
9543
|
type: "leader-failed",
|
|
9541
9544
|
tabId: this.deps.tabId,
|
|
@@ -10884,7 +10887,7 @@ var Sp00kyClient = class {
|
|
|
10884
10887
|
return new TabsCoordinator({
|
|
10885
10888
|
tabId,
|
|
10886
10889
|
fingerprint: computeTabsFingerprint({
|
|
10887
|
-
coreVersion: "0.0.1-canary.
|
|
10890
|
+
coreVersion: "0.0.1-canary.167",
|
|
10888
10891
|
schemaHash: hash53(this.config.schemaSurql),
|
|
10889
10892
|
endpoint: this.config.database.endpoint ?? "",
|
|
10890
10893
|
namespace: this.config.database.namespace,
|
|
@@ -10998,23 +11001,27 @@ var Sp00kyClient = class {
|
|
|
10998
11001
|
this.logger.info({ Category: "sp00ky-client::Sp00kyClient::init" }, "Sp00kyClient initialization started");
|
|
10999
11002
|
try {
|
|
11000
11003
|
const bootBucket = readBootBucketHint() ?? ANON_USER_ID;
|
|
11001
|
-
if (this.tabsCoordinator)
|
|
11002
|
-
|
|
11003
|
-
|
|
11004
|
-
|
|
11005
|
-
|
|
11006
|
-
bootBucket
|
|
11007
|
-
|
|
11008
|
-
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
}
|
|
11014
|
-
|
|
11015
|
-
|
|
11016
|
-
|
|
11017
|
-
|
|
11004
|
+
if (this.tabsCoordinator) {
|
|
11005
|
+
this.tabsCoordinator.onRoleChange((role) => {
|
|
11006
|
+
this.sharedActive = role !== "solo";
|
|
11007
|
+
});
|
|
11008
|
+
try {
|
|
11009
|
+
const role = await this.tabsCoordinator.start(bootBucket);
|
|
11010
|
+
this.sharedActive = true;
|
|
11011
|
+
this.logger.info({
|
|
11012
|
+
role,
|
|
11013
|
+
bootBucket,
|
|
11014
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
11015
|
+
}, "Shared-tabs role assigned");
|
|
11016
|
+
} catch (e) {
|
|
11017
|
+
this.logger.warn({
|
|
11018
|
+
err: e,
|
|
11019
|
+
Category: "sp00ky-client::Sp00kyClient::init"
|
|
11020
|
+
}, "Shared-tabs unavailable; booting solo");
|
|
11021
|
+
this.sharedActive = false;
|
|
11022
|
+
await this.local.connect(bootBucket);
|
|
11023
|
+
}
|
|
11024
|
+
} else await this.local.connect(bootBucket);
|
|
11018
11025
|
this.logger.debug({
|
|
11019
11026
|
bootBucket,
|
|
11020
11027
|
Category: "sp00ky-client::Sp00kyClient::init"
|
|
@@ -3,7 +3,7 @@ const PING_INTERVAL_MS = 5e3;
|
|
|
3
3
|
const PONG_TIMEOUT_MS = 15e3;
|
|
4
4
|
const FORCE_TAKEOVER_TIMEOUT_MS = 1e3;
|
|
5
5
|
const LEADER_FAILURE_BACKOFF_MS = 1e3;
|
|
6
|
-
const
|
|
6
|
+
const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
7
7
|
const brokerInstanceId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `bk_${Math.random().toString(36).slice(2)}`;
|
|
8
8
|
const namespaces = /* @__PURE__ */ new Map();
|
|
9
9
|
/** Reverse index: which namespace a port belongs to (for pong routing). */
|
|
@@ -32,7 +32,7 @@ function getNamespace(fingerprint, bucketId) {
|
|
|
32
32
|
tabs: /* @__PURE__ */ new Map(),
|
|
33
33
|
leader: null,
|
|
34
34
|
failedUntil: /* @__PURE__ */ new Map(),
|
|
35
|
-
|
|
35
|
+
failedCycles: 0,
|
|
36
36
|
electing: false,
|
|
37
37
|
attachRetry: /* @__PURE__ */ new Map(),
|
|
38
38
|
tabLockMonitor: null
|
|
@@ -171,7 +171,7 @@ function electIfNeeded(ns, previous = null) {
|
|
|
171
171
|
brokerInstanceId,
|
|
172
172
|
leadershipId,
|
|
173
173
|
forceTakeover,
|
|
174
|
-
allowMemoryFallback: ns.
|
|
174
|
+
allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
|
|
175
175
|
resumeHeld
|
|
176
176
|
});
|
|
177
177
|
} finally {
|
|
@@ -358,7 +358,7 @@ function handleTabMessage(port, msg, ports) {
|
|
|
358
358
|
break;
|
|
359
359
|
}
|
|
360
360
|
ns.leader.ready = true;
|
|
361
|
-
ns.
|
|
361
|
+
ns.failedCycles = 0;
|
|
362
362
|
startTabLockMonitor(ns, msg.leadershipId);
|
|
363
363
|
const tab = ns.tabs.get(msg.tabId);
|
|
364
364
|
if (tab) tab.heldLeadership = {
|
|
@@ -376,7 +376,7 @@ function handleTabMessage(port, msg, ports) {
|
|
|
376
376
|
}
|
|
377
377
|
case "leader-failed": {
|
|
378
378
|
if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
|
|
379
|
-
|
|
379
|
+
ns.failedCycles += 1;
|
|
380
380
|
ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
381
381
|
const previous = clearLeader(ns, {
|
|
382
382
|
demote: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.167",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.167",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.167",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
67
|
"fast-json-patch": "^3.1.1",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import { handleConnect, __resetBrokerForTests } from './tabs-broker-worker';
|
|
3
|
-
import { installBrokerGlobals } from './fake-ports.fixture';
|
|
3
|
+
import { installBrokerGlobals, installFakeLocks } from './fake-ports.fixture';
|
|
4
4
|
import { TabsCoordinator, type CoordinatorHooks, type LeaderSyncHub, type SyncForwarder } from './coordinator';
|
|
5
5
|
import type { StorageHealth } from '../../types';
|
|
6
6
|
import type { LeaderToFollowerMessage } from './protocol';
|
|
@@ -62,12 +62,12 @@ function makeHooks(): { hooks: CoordinatorHooks; log: HookLog } {
|
|
|
62
62
|
return { hooks, log };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
function makeCoordinator(tabId: string) {
|
|
65
|
+
function makeCoordinator(tabId: string, overrides: Partial<CoordinatorHooks> = {}) {
|
|
66
66
|
const { hooks, log } = makeHooks();
|
|
67
67
|
const coordinator = new TabsCoordinator({
|
|
68
68
|
tabId,
|
|
69
69
|
fingerprint: 'fp-coord-test',
|
|
70
|
-
hooks,
|
|
70
|
+
hooks: { ...hooks, ...overrides },
|
|
71
71
|
logger: makeLogger(),
|
|
72
72
|
});
|
|
73
73
|
return { coordinator, log };
|
|
@@ -171,6 +171,58 @@ describe('TabsCoordinator integration', () => {
|
|
|
171
171
|
await b.coordinator.stop();
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
// A failed promotion must give the leader TAB lock back. The name is shared
|
|
175
|
+
// per namespace, so a leaked one makes every later election fail with
|
|
176
|
+
// 'leader tab lock unavailable': all tabs time out in start(), boot solo, and
|
|
177
|
+
// then contend for the OPFS pool individually — one busy pool wedging the
|
|
178
|
+
// whole app into the pre-shared-tabs behavior it exists to replace.
|
|
179
|
+
describe('with Web Locks', () => {
|
|
180
|
+
let locks: ReturnType<typeof installFakeLocks>;
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
locks = installFakeLocks();
|
|
183
|
+
});
|
|
184
|
+
afterEach(() => locks.restore());
|
|
185
|
+
|
|
186
|
+
/** A tab whose store can never open. Resolves once it has failed a
|
|
187
|
+
* promotion, so tests don't wait out start()'s 15s timeout. */
|
|
188
|
+
function makeDoomedTab(tabId: string) {
|
|
189
|
+
let failed!: () => void;
|
|
190
|
+
const hasFailed = new Promise<void>((r) => {
|
|
191
|
+
failed = r;
|
|
192
|
+
});
|
|
193
|
+
const tab = makeCoordinator(tabId, {
|
|
194
|
+
async adoptOwner() {
|
|
195
|
+
queueMicrotask(failed);
|
|
196
|
+
throw new Error('opfs-unavailable: NoModificationAllowedError (after 10 attempts)');
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
// start() only settles on a role or the timeout; neither is the point.
|
|
200
|
+
void tab.coordinator.start('anon').catch(() => {});
|
|
201
|
+
return { ...tab, hasFailed };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
it('releases the leader tab lock when adoptOwner throws', async () => {
|
|
205
|
+
const a = makeDoomedTab('tab-1');
|
|
206
|
+
await a.hasFailed;
|
|
207
|
+
// Well inside the 1s re-nomination backoff, so a held lock here is a leak
|
|
208
|
+
// and not the next attempt's legitimate acquisition.
|
|
209
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
210
|
+
expect(locks.heldNames()).not.toContain('sp00ky-tabs:fp-coord-test:anon:tab');
|
|
211
|
+
await a.coordinator.stop();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('lets a later tab lead after another tab failed to open the store', async () => {
|
|
215
|
+
const a = makeDoomedTab('tab-1');
|
|
216
|
+
await a.hasFailed;
|
|
217
|
+
|
|
218
|
+
const b = makeCoordinator('tab-2');
|
|
219
|
+
await expect(b.coordinator.start('anon')).resolves.toBe('leader');
|
|
220
|
+
expect(b.log.adoptOwner).toHaveLength(1);
|
|
221
|
+
await b.coordinator.stop();
|
|
222
|
+
await a.coordinator.stop();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
174
226
|
it('moves buckets by leaving and rejoining: old namespace re-elects', async () => {
|
|
175
227
|
const a = makeCoordinator('tab-1');
|
|
176
228
|
await a.coordinator.start('anon');
|
|
@@ -424,6 +424,15 @@ export class TabsCoordinator {
|
|
|
424
424
|
);
|
|
425
425
|
this.hub?.detachAll();
|
|
426
426
|
this.hub = null;
|
|
427
|
+
// Give back everything this attempt claimed. The broker does NOT demote a
|
|
428
|
+
// tab whose promotion failed (leader-failed clears leadership without a
|
|
429
|
+
// demote), so nothing else ever frees these. The tab lock name is shared
|
|
430
|
+
// per namespace, so keeping it after failing to lead makes EVERY later
|
|
431
|
+
// election in this namespace fail with 'leader tab lock unavailable' —
|
|
432
|
+
// one OPFS-busy promotion would wedge the whole app into solo mode.
|
|
433
|
+
if (previousRole === 'leader') await this.deps.hooks.releaseOwnership();
|
|
434
|
+
this.tabLock?.release();
|
|
435
|
+
this.tabLock = null;
|
|
427
436
|
this.broker.send({
|
|
428
437
|
type: 'leader-failed',
|
|
429
438
|
tabId: this.deps.tabId,
|
|
@@ -39,6 +39,48 @@ export async function flush(times = 10): Promise<void> {
|
|
|
39
39
|
for (let i = 0; i < times; i++) await Promise.resolve();
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Install a minimal exclusive-only `navigator.locks` on globalThis. Node has no
|
|
44
|
+
* Web Locks, and `acquireLeaderTabLock` treats a missing LockManager as "always
|
|
45
|
+
* granted", so without this the whole leader-tab-lock path is a no-op in tests
|
|
46
|
+
* and lock leaks are invisible. `ifAvailable` resolves null while held (what a
|
|
47
|
+
* losing tab sees), `steal` evicts the holder, and a plain request queues
|
|
48
|
+
* forever — the same shapes the real API produces.
|
|
49
|
+
*/
|
|
50
|
+
export function installFakeLocks(): {
|
|
51
|
+
restore: () => void;
|
|
52
|
+
heldNames: () => string[];
|
|
53
|
+
} {
|
|
54
|
+
const g = globalThis as Record<string, unknown>;
|
|
55
|
+
const previous = g.navigator;
|
|
56
|
+
const held = new Map<string, () => void>();
|
|
57
|
+
const locks = {
|
|
58
|
+
async request(name: string, opts: any, cb: any) {
|
|
59
|
+
if (opts?.steal) held.get(name)?.();
|
|
60
|
+
if (held.has(name) && !opts?.steal) {
|
|
61
|
+
if (opts?.ifAvailable) return cb(null);
|
|
62
|
+
return new Promise(() => {});
|
|
63
|
+
}
|
|
64
|
+
// The holder keeps the lock until the callback's promise settles (the
|
|
65
|
+
// real contract) or someone steals it out from under them.
|
|
66
|
+
let stolen!: () => void;
|
|
67
|
+
const stealSignal = new Promise<void>((r) => {
|
|
68
|
+
stolen = r as () => void;
|
|
69
|
+
});
|
|
70
|
+
held.set(name, stolen);
|
|
71
|
+
try {
|
|
72
|
+
await Promise.race([Promise.resolve(cb({ name, mode: 'exclusive' })), stealSignal]);
|
|
73
|
+
} finally {
|
|
74
|
+
held.delete(name);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const define = (value: unknown) =>
|
|
79
|
+
Object.defineProperty(g, 'navigator', { value, configurable: true, writable: true });
|
|
80
|
+
define({ ...(previous ?? {}), locks });
|
|
81
|
+
return { restore: () => define(previous), heldNames: () => [...held.keys()] };
|
|
82
|
+
}
|
|
83
|
+
|
|
42
84
|
/** Install `MessageChannel` + `SharedWorker` fakes on globalThis; the fake
|
|
43
85
|
* SharedWorker pipes its port into `handleConnect`. Returns a restore fn. */
|
|
44
86
|
export function installBrokerGlobals(handleConnect: (port: MessagePort) => void): () => void {
|
|
@@ -44,10 +44,13 @@ export const LEADER_FAILURE_BACKOFF_MS = 1000;
|
|
|
44
44
|
/** Follower attachment retry: initial delay, doubling per attempt, capped. */
|
|
45
45
|
export const ATTACH_RETRY_INITIAL_MS = 1000;
|
|
46
46
|
export const ATTACH_RETRY_MAX_MS = 30_000;
|
|
47
|
-
/** After this many consecutive elections
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
47
|
+
/** After this many consecutive failed elections, the broker allows the next
|
|
48
|
+
* leader to open in memory (reported as degraded) rather than leaving the
|
|
49
|
+
* namespace leaderless forever. Counts EVERY failure reason, not just
|
|
50
|
+
* opfs-unavailable: any reason that keeps recurring leaves every tab timing
|
|
51
|
+
* out in `start()` and falling back to solo, which is strictly worse than one
|
|
52
|
+
* shared in-memory store. */
|
|
53
|
+
export const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
51
54
|
|
|
52
55
|
// ---- identity ---------------------------------------------------------------
|
|
53
56
|
|
|
@@ -35,7 +35,7 @@ const PING_INTERVAL_MS = 5000;
|
|
|
35
35
|
const PONG_TIMEOUT_MS = 15_000;
|
|
36
36
|
const FORCE_TAKEOVER_TIMEOUT_MS = 1000;
|
|
37
37
|
const LEADER_FAILURE_BACKOFF_MS = 1000;
|
|
38
|
-
const
|
|
38
|
+
const FAILED_CYCLES_BEFORE_MEMORY = 3;
|
|
39
39
|
|
|
40
40
|
interface BrokerTab {
|
|
41
41
|
port: MessagePort;
|
|
@@ -62,8 +62,8 @@ interface Namespace {
|
|
|
62
62
|
leader: Leader | null;
|
|
63
63
|
/** Tabs whose promotion recently failed: tabId -> retry-not-before. */
|
|
64
64
|
failedUntil: Map<string, number>;
|
|
65
|
-
/** Consecutive elections
|
|
66
|
-
|
|
65
|
+
/** Consecutive elections whose promotion failed, for any reason. */
|
|
66
|
+
failedCycles: number;
|
|
67
67
|
/** Single-flight guard for the async election path. */
|
|
68
68
|
electing: boolean;
|
|
69
69
|
/** Pending follower attach state: followerTabId -> retry bookkeeping. */
|
|
@@ -112,7 +112,7 @@ function getNamespace(fingerprint: string, bucketId: string): Namespace {
|
|
|
112
112
|
tabs: new Map(),
|
|
113
113
|
leader: null,
|
|
114
114
|
failedUntil: new Map(),
|
|
115
|
-
|
|
115
|
+
failedCycles: 0,
|
|
116
116
|
electing: false,
|
|
117
117
|
attachRetry: new Map(),
|
|
118
118
|
tabLockMonitor: null,
|
|
@@ -274,7 +274,7 @@ function electIfNeeded(ns: Namespace, previous: ClearedLeader | null = null): vo
|
|
|
274
274
|
brokerInstanceId,
|
|
275
275
|
leadershipId,
|
|
276
276
|
forceTakeover,
|
|
277
|
-
allowMemoryFallback: ns.
|
|
277
|
+
allowMemoryFallback: ns.failedCycles >= FAILED_CYCLES_BEFORE_MEMORY,
|
|
278
278
|
resumeHeld,
|
|
279
279
|
});
|
|
280
280
|
} finally {
|
|
@@ -488,7 +488,7 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
488
488
|
break;
|
|
489
489
|
}
|
|
490
490
|
ns.leader.ready = true;
|
|
491
|
-
ns.
|
|
491
|
+
ns.failedCycles = 0;
|
|
492
492
|
startTabLockMonitor(ns, msg.leadershipId);
|
|
493
493
|
const tab = ns.tabs.get(msg.tabId);
|
|
494
494
|
if (tab) {
|
|
@@ -512,7 +512,12 @@ function handleTabMessage(port: MessagePort, msg: TabToBrokerMessage, ports: rea
|
|
|
512
512
|
}
|
|
513
513
|
case 'leader-failed': {
|
|
514
514
|
if (ns.leader?.tabId !== msg.tabId || ns.leader.leadershipId !== msg.leadershipId) break;
|
|
515
|
-
|
|
515
|
+
// Every reason counts. Gating this on 'opfs-unavailable' left any other
|
|
516
|
+
// recurring failure (e.g. a tab lock nobody frees) looping at the backoff
|
|
517
|
+
// interval forever, with allowMemoryFallback never granted, so every tab
|
|
518
|
+
// timed out in start() and booted solo — each one then contending for the
|
|
519
|
+
// OPFS pool on its own, which is exactly what shared-tabs exists to stop.
|
|
520
|
+
ns.failedCycles += 1;
|
|
516
521
|
ns.failedUntil.set(msg.tabId, Date.now() + LEADER_FAILURE_BACKOFF_MS);
|
|
517
522
|
const previous = clearLeader(ns, { demote: false, removeTab: false });
|
|
518
523
|
const tab = ns.tabs.get(msg.tabId);
|
package/src/sp00ky.ts
CHANGED
|
@@ -676,6 +676,13 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
676
676
|
// Any failure here (no SharedWorker start, election timeout, rejected
|
|
677
677
|
// fingerprint) falls back to plain solo boot: exactly the flag-off
|
|
678
678
|
// path, including the second-tab memory fallback + its warning.
|
|
679
|
+
// A role can still land AFTER start() gave up (the election that timed
|
|
680
|
+
// out here keeps running), and when it does this tab really is sharing
|
|
681
|
+
// the leader's store. Track every transition so the reported state is
|
|
682
|
+
// the current one instead of frozen at whatever boot saw.
|
|
683
|
+
this.tabsCoordinator.onRoleChange((role) => {
|
|
684
|
+
this.sharedActive = role !== 'solo';
|
|
685
|
+
});
|
|
679
686
|
try {
|
|
680
687
|
const role = await this.tabsCoordinator.start(bootBucket);
|
|
681
688
|
this.sharedActive = true;
|