Skip to content
Closed
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
75 changes: 75 additions & 0 deletions packages/browser-utils/src/coexistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,81 @@ describe('createCoexistence — the dead-sibling age bound, end to end', () => {
});
});

describe('createCoexistence — clean release', () => {
it('release() frees the instance lock so the next launch recovers this instance as a dead sibling', async () => {
const idb = new IDBFactory();
const held = new Set<string>();
const locks: LockManagerLike = {
request(name, options, callback) {
if (options.ifAvailable && held.has(name)) {
return Promise.resolve(callback(null));
}
held.add(name);
return Promise.resolve(callback({ name })).finally(() => held.delete(name));
},
};
const first = createCoexistence({ appToken: TOK, persist: true, indexedDB: idb, locks });
const store = first.bundleStore as NonNullable<typeof first.bundleStore> & {
whenReady: Promise<void>;
};
await store.whenReady;
store.put('left-over', serializeBundle(aBundle('left over')));
await vi.waitFor(async () => expect(await rawKeys(idb)).toHaveLength(1));
expect(held.size).toBe(1);

// While held, a second instance must leave it alone (live).
const second = createCoexistence({ appToken: TOK, persist: true, indexedDB: idb, locks });
const pipeline = okPipeline();
await second.recoverDeadSiblings({ uploadPipeline: pipeline });
expect(pipeline.enqueue).not.toHaveBeenCalled();

first.release();
first.release(); // idempotent
await vi.waitFor(() => expect(held.size).toBe(1)); // only second's own lock remains
await second.recoverDeadSiblings({ uploadPipeline: pipeline });
expect(pipeline.enqueue).toHaveBeenCalledTimes(1); // released -> dead -> recovered
});

it('release() closes every shared IndexedDB connection (bundles, capture and markers)', async () => {
const real = new IDBFactory();
let open = 0;
const idb = {
open: (name: string, version?: number) => {
const request = real.open(name, version);
request.addEventListener('success', () => {
open += 1;
const db = request.result;
const close = db.close.bind(db);
db.close = () => {
open -= 1;
close();
};
});
return request;
},
} as unknown as IDBFactory;
const coex = createCoexistence({
appToken: TOK,
persist: true,
captureRecovery: true,
indexedDB: idb,
locks: fakeLocks().manager,
});
// Touch all three databases so each opens a connection.
await (coex.bundleStore as unknown as { whenReady: Promise<void> }).whenReady;
await coex.captureView?.put('k', new Uint8Array([1]));
await coex.markerView?.loadAll();
expect(open).toBe(3);
coex.release();
await vi.waitFor(() => expect(open).toBe(0));
});

it('release() with nothing coexisting (no persistence) is a harmless no-op', () => {
const coex = createCoexistence({ appToken: TOK, persist: false });
expect(() => coex.release()).not.toThrow();
});
});

describe('createCoexistence — bundle queue', () => {
it('writes under a per-token database + per-instance prefix and holds the instance lock', async () => {
const idb = new IDBFactory();
Expand Down
16 changes: 13 additions & 3 deletions packages/browser-utils/src/coexistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ export interface Coexistence {
/** After launch: recover every DEAD sibling (re-upload its bundles + run its capture/marker recovery), each
* under its own lock. A no-op when this instance has no coexisting durable data. Safe to fire-and-forget. */
recoverDeadSiblings(options: RecoverDeadSiblingsOptions): Promise<void>;
/** Clean shutdown (idempotent): release the instance liveness lock — so this instance reads as dead and
* its leftovers are recoverable by the next launch — and close the shared IndexedDB connections. Call it
* AFTER the capture store has flushed; a later use of a store lazily reopens its connection. */
release(): void;
}

/** Build the multi-instance-safe coexistence root for a launch (bundle queue + optional capture/markers). */
Expand Down Expand Up @@ -154,15 +158,21 @@ export function createCoexistence(options: CoexistenceOptions): Coexistence {
onError !== undefined ? (message) => onError(new Error(message)) : undefined,
);
const coexisting = bundleShared !== undefined || captureShared !== undefined;
if (coexisting) {
liveness.holdSelf(instanceLockName(appToken, instanceId));
}
const releaseLock = coexisting
? liveness.holdSelf(instanceLockName(appToken, instanceId))
: () => {};

return {
instanceId,
bundleStore,
captureView,
markerView,
release: () => {
releaseLock();
bundleShared?.close?.();
captureShared?.close?.();
markerShared?.close?.();
},
recoverDeadSiblings: async ({
uploadPipeline,
recoverReportsForSibling,
Expand Down
62 changes: 62 additions & 0 deletions packages/browser-utils/src/idb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import { createIdbBlobStore, createIdbKeyedStore } from './idb';

const bytes = (...n: number[]) => new Uint8Array(n);

// An IDBFactory that counts connections opened and closed, so a clean shutdown can be asserted.
function countingIdb(real: IDBFactory): { idb: IDBFactory; open: () => number } {
let open = 0;
const idb = {
open: (name: string, version?: number) => {
const request = real.open(name, version);
request.addEventListener('success', () => {
open += 1;
const db = request.result;
const originalClose = db.close.bind(db);
db.close = () => {
open -= 1;
originalClose();
};
});
return request;
},
} as unknown as IDBFactory;
return { idb, open: () => open };
}

afterEach(() => {
vi.unstubAllGlobals();
});
Expand Down Expand Up @@ -366,3 +387,44 @@ describe('createIdbBlobStore — error handling', () => {
await expect(store.loadAll()).rejects.toThrow('indexedDB loadAll failed');
});
});

describe('close (clean shutdown)', () => {
it('releases the blob store connection and lazily reopens on a later call', async () => {
const { idb, open } = countingIdb(new IDBFactory());
const store = createIdbBlobStore({ indexedDB: idb });
await store.put('a', bytes(1));
expect(open()).toBe(1);
store.close?.();
await vi.waitFor(() => expect(open()).toBe(0));
store.close?.(); // idempotent
expect(await store.loadAll()).toEqual([['a', bytes(1)]]); // data intact, connection reopened
expect(open()).toBe(1);
});

it('releases the keyed store connection and lazily reopens on a later call', async () => {
const { idb, open } = countingIdb(new IDBFactory());
const store = createIdbKeyedStore({ indexedDB: idb });
await store.put('k/1', bytes(2));
expect(open()).toBe(1);
store.close?.();
await vi.waitFor(() => expect(open()).toBe(0));
expect(await store.keys('k/')).toEqual(['k/1']);
expect(open()).toBe(1);
});

it('close before any use is a no-op, and a failed open does not throw on close', async () => {
const store = createIdbBlobStore({ indexedDB: new IDBFactory() });
expect(() => store.close?.()).not.toThrow();
const failing = {
open: () => {
const request = {} as IDBOpenDBRequest;
queueMicrotask(() => (request.onerror as () => void)());
return request;
},
} as unknown as IDBFactory;
const broken = createIdbBlobStore({ indexedDB: failing });
await expect(broken.loadAll()).rejects.toBeDefined();
expect(() => broken.close?.()).not.toThrow();
await Promise.resolve();
});
});
29 changes: 29 additions & 0 deletions packages/browser-utils/src/idb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface AsyncBlobStore {
put(id: string, bytes: Uint8Array): Promise<void>;
/** Remove the value under `id`; a no-op if absent. */
remove(id: string): Promise<void>;
/** Close the database connection (a later call lazily reopens it). Optional: wrappers/fakes may omit it. */
close?(): void;
}

export interface IdbBlobStoreOptions {
Expand All @@ -22,6 +24,14 @@ export interface IdbBlobStoreOptions {
indexedDB?: IDBFactory;
}

// Close a connection once it has opened; a connection that never opened has nothing to close.
const closeQuietly = (pending: Promise<IDBDatabase> | undefined): void => {
void pending?.then(
(db) => db.close(),
() => undefined,
);
};

const reqError = (req: { error: DOMException | null }): Error =>
req.error ?? new Error('indexedDB request failed');

Expand All @@ -48,6 +58,13 @@ export function createIdbBlobStore(options: IdbBlobStoreOptions = {}): AsyncBlob
return dbPromise;
};

// Release the connection (clean shutdown). A later call lazily reopens, so a straggling write is never lost.
const close = (): void => {
const pending = dbPromise;
dbPromise = undefined;
closeQuietly(pending);
};

// Run a single-request read/write transaction and resolve with the request result — ON COMMIT.
//
// IndexedDB makes a write durable at COMMIT, not at request success: a transaction can report every
Expand Down Expand Up @@ -92,6 +109,7 @@ export function createIdbBlobStore(options: IdbBlobStoreOptions = {}): AsyncBlob
),
put: (id, bytes) => run('readwrite', (store) => store.put(bytes, id)).then(() => undefined),
remove: (id) => run('readwrite', (store) => store.delete(id)).then(() => undefined),
close,
};
}

Expand All @@ -111,6 +129,8 @@ export interface AsyncKeyedStore {
keys(prefix: string): Promise<string[]>;
/** Delete every key starting with `prefix`; a no-op if none match. */
deletePrefix(prefix: string): Promise<void>;
/** Close the database connection (a later call lazily reopens it). Optional: wrappers/fakes may omit it. */
close?(): void;
}

/** Build an {@link AsyncKeyedStore} over IndexedDB (lazily opening the database on first use). */
Expand All @@ -134,6 +154,13 @@ export function createIdbKeyedStore(options: IdbBlobStoreOptions = {}): AsyncKey
return dbPromise;
};

// Release the connection (clean shutdown). A later call lazily reopens, so a straggling write is never lost.
const close = (): void => {
const pending = dbPromise;
dbPromise = undefined;
closeQuietly(pending);
};

// The inclusive prefix range: all keys k with prefix ≤ k ≤ prefix+'￿'. With fixed-width padded
// keys this matches exactly the intended chunk/generation group and nothing beyond it.
const prefixRange = (prefix: string): IDBKeyRange => IDBKeyRange.bound(prefix, `${prefix}￿`);
Expand Down Expand Up @@ -197,5 +224,7 @@ export function createIdbKeyedStore(options: IdbBlobStoreOptions = {}): AsyncKey
request.onerror = () => reject(reqError(request));
}),
),

close,
};
}
40 changes: 40 additions & 0 deletions packages/browser-utils/src/web-lock-liveness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,46 @@ describe('createWebLockLiveness — available', () => {
});
});

// A manager that, unlike the lifetime fake above, frees a held lock when its callback's promise settles —
// the real Web Locks contract, which is what `holdSelf`'s release relies on.
function releasingLocks() {
const held = new Set<string>();
const manager: LockManagerLike = {
request(name, options, callback) {
if (options.ifAvailable && held.has(name)) {
return Promise.resolve(callback(null));
}
held.add(name);
return Promise.resolve(callback({ name })).finally(() => held.delete(name));
},
};
return { manager, held };
}

describe('createWebLockLiveness — clean release', () => {
it('holdSelf returns a release that frees the lock so the instance reads as dead', async () => {
const locks = releasingLocks();
const liveness = createWebLockLiveness(locks.manager);
const release = liveness.holdSelf('inst-A');
expect(locks.held.has('inst-A')).toBe(true);
const skipped = vi.fn(() => Promise.resolve());
await liveness.recoverIfDead('inst-A', skipped);
expect(skipped).not.toHaveBeenCalled(); // alive while held

release();
release(); // idempotent
await vi.waitFor(() => expect(locks.held.has('inst-A')).toBe(false));
const ran = vi.fn(() => Promise.resolve());
await liveness.recoverIfDead('inst-A', ran);
expect(ran).toHaveBeenCalledTimes(1); // released -> dead -> recoverable
});

it('the degraded (no Web Locks) holdSelf returns a callable no-op release', () => {
const release = createWebLockLiveness(undefined).holdSelf('x');
expect(() => release()).not.toThrow();
});
});

describe('createWebLockLiveness — unavailable (degrade)', () => {
it('warns ONCE on holdSelf and never cross-recovers', async () => {
const warn = vi.fn();
Expand Down
17 changes: 13 additions & 4 deletions packages/browser-utils/src/web-lock-liveness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ export interface LockManagerLike {
export interface WebLockLiveness {
/** False when Web Locks is unavailable — cross-instance recovery then degrades to a no-op. */
readonly available: boolean;
/** Acquire `name` and HOLD it for the realm's lifetime (released automatically on tab/worker death). */
holdSelf(name: string): void;
/** Acquire `name` and HOLD it for the realm's lifetime (released automatically on tab/worker death).
* Returns an idempotent `release` for a CLEAN shutdown (`stop()`), so the instance reads as dead and its
* leftovers become recoverable by the next launch. */
holdSelf(name: string): () => void;
/** If `name` is DEAD (its lock is acquirable), run `fn` while HOLDING the lock (so concurrent peers skip),
* then release. Resolves immediately (no-op) when the lock is held (alive) or Web Locks is unavailable. */
recoverIfDead(name: string, fn: () => Promise<void>): Promise<void>;
Expand All @@ -43,21 +45,28 @@ export function createWebLockLiveness(
warned = true;
warn(WEB_LOCKS_UNAVAILABLE_WARNING);
}
return () => {};
},
recoverIfDead: () => Promise.resolve(),
};
}
return {
available: true,
holdSelf(name) {
// Hold for the realm's lifetime: a never-resolving callback keeps the lock until the realm is destroyed.
// Hold for the realm's lifetime: a callback that only settles on `release` keeps the lock until the
// realm is destroyed or the instance shuts down cleanly.
// A rejected request (e.g. an invalid lock name, or a sandboxed context) must NOT surface as an
// unhandledrejection — Bugsee would self-report its own internal lock failure as an app error. Swallow
// it to `warn` and degrade (this instance simply won't be lock-protected; siblings may double-recover
// its bundles, which the server dedupes by signature).
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
void locks
.request(name, { mode: 'exclusive' }, () => new Promise<never>(() => {}))
.request(name, { mode: 'exclusive' }, () => held)
.catch((error) => warn(`failed to hold the instance liveness lock: ${String(error)}`));
return release;
},
recoverIfDead(name, fn) {
return locks.request(name, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Browser platform: fetch transport, IndexedDB storage, window error handlers, stack-trace parser,
// and the launch() composition root. Tier 2. See docs/design/sdk-design.md §5.

export { BugseeStatus } from '@bugsee/core';
export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name';
export {
createUnhandledRejectionProvider,
Expand Down Expand Up @@ -42,10 +43,12 @@ export {
export {
type Bugsee,
type BugseeLaunchOptions,
getStatus,
type LaunchInternals,
type LaunchResult,
launch,
launchCore,
relaunch,
} from './launch';
export {
type MetaTraceContinuation,
Expand Down
Loading
Loading