diff --git a/packages/browser-utils/src/coexistence.test.ts b/packages/browser-utils/src/coexistence.test.ts index bcab9f7e..6b826519 100644 --- a/packages/browser-utils/src/coexistence.test.ts +++ b/packages/browser-utils/src/coexistence.test.ts @@ -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(); + 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 & { + whenReady: Promise; + }; + 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 }).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(); diff --git a/packages/browser-utils/src/coexistence.ts b/packages/browser-utils/src/coexistence.ts index 1644350f..e6b2559b 100644 --- a/packages/browser-utils/src/coexistence.ts +++ b/packages/browser-utils/src/coexistence.ts @@ -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; + /** 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). */ @@ -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, diff --git a/packages/browser-utils/src/idb.test.ts b/packages/browser-utils/src/idb.test.ts index dd6d5dcf..e1ca6830 100644 --- a/packages/browser-utils/src/idb.test.ts +++ b/packages/browser-utils/src/idb.test.ts @@ -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(); }); @@ -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(); + }); +}); diff --git a/packages/browser-utils/src/idb.ts b/packages/browser-utils/src/idb.ts index b6c3cea1..f29b88b9 100644 --- a/packages/browser-utils/src/idb.ts +++ b/packages/browser-utils/src/idb.ts @@ -11,6 +11,8 @@ export interface AsyncBlobStore { put(id: string, bytes: Uint8Array): Promise; /** Remove the value under `id`; a no-op if absent. */ remove(id: string): Promise; + /** Close the database connection (a later call lazily reopens it). Optional: wrappers/fakes may omit it. */ + close?(): void; } export interface IdbBlobStoreOptions { @@ -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 | undefined): void => { + void pending?.then( + (db) => db.close(), + () => undefined, + ); +}; + const reqError = (req: { error: DOMException | null }): Error => req.error ?? new Error('indexedDB request failed'); @@ -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 @@ -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, }; } @@ -111,6 +129,8 @@ export interface AsyncKeyedStore { keys(prefix: string): Promise; /** Delete every key starting with `prefix`; a no-op if none match. */ deletePrefix(prefix: string): Promise; + /** 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). */ @@ -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}￿`); @@ -197,5 +224,7 @@ export function createIdbKeyedStore(options: IdbBlobStoreOptions = {}): AsyncKey request.onerror = () => reject(reqError(request)); }), ), + + close, }; } diff --git a/packages/browser-utils/src/web-lock-liveness.test.ts b/packages/browser-utils/src/web-lock-liveness.test.ts index 045a065b..e1b53151 100644 --- a/packages/browser-utils/src/web-lock-liveness.test.ts +++ b/packages/browser-utils/src/web-lock-liveness.test.ts @@ -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(); + 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(); diff --git a/packages/browser-utils/src/web-lock-liveness.ts b/packages/browser-utils/src/web-lock-liveness.ts index fc75f94a..e680c759 100644 --- a/packages/browser-utils/src/web-lock-liveness.ts +++ b/packages/browser-utils/src/web-lock-liveness.ts @@ -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): Promise; @@ -43,6 +45,7 @@ export function createWebLockLiveness( warned = true; warn(WEB_LOCKS_UNAVAILABLE_WARNING); } + return () => {}; }, recoverIfDead: () => Promise.resolve(), }; @@ -50,14 +53,20 @@ export function createWebLockLiveness( 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((resolve) => { + release = resolve; + }); void locks - .request(name, { mode: 'exclusive' }, () => new Promise(() => {})) + .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) => { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index e0d2f3ef..1187ea64 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -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, @@ -42,10 +43,12 @@ export { export { type Bugsee, type BugseeLaunchOptions, + getStatus, type LaunchInternals, type LaunchResult, launch, launchCore, + relaunch, } from './launch'; export { type MetaTraceContinuation, diff --git a/packages/browser/src/launch-lifecycle.test.ts b/packages/browser/src/launch-lifecycle.test.ts new file mode 100644 index 00000000..02b9aa71 --- /dev/null +++ b/packages/browser/src/launch-lifecycle.test.ts @@ -0,0 +1,386 @@ +import 'fake-indexeddb/auto'; +import type { LockManagerLike } from '@bugsee/browser-utils'; +import { + BugseeStatus, + createMemoryCaptureStore, + getCarrier, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { BugseeOption } from '@bugsee/protocol'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetBrowserDeviceIdCache } from './device-id'; +import type { BrowserProbe } from './environment'; +import { + type Bugsee, + type BugseeLaunchOptions, + getStatus, + launch, + launchCore, + relaunch, +} from './launch'; + +// Lifecycle (launch -> stop -> launch) behaviour of the browser composition root: status, relaunch, the +// introspectable launch options, and — the point — that a repeat cycle leaves nothing behind. + +vi.mock('@bugsee/replay', () => ({ registerReplay: vi.fn() })); + +const TEST_DEVICE_ID = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'; +const probe: BrowserProbe = { + userAgent: () => + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + uaDataPlatform: () => 'macOS', + locale: () => 'en-US', + utcOffsetMinutes: () => 0, + screenWidth: () => 1280, + screenHeight: () => 720, + pixelRatio: () => 1, + deviceMemoryBytes: () => undefined, + cpuCount: () => undefined, +}; + +// A window/document fake that counts live listeners per type. +function fakeTarget() { + const listeners = new Map void>>(); + const target = { + addEventListener(type: string, listener: (event: Event) => void) { + const set = listeners.get(type) ?? new Set(); + set.add(listener); + listeners.set(type, set); + }, + removeEventListener(type: string, listener: (event: Event) => void) { + listeners.get(type)?.delete(listener); + }, + }; + return { + target, + total: () => [...listeners.values()].reduce((n, s) => n + s.size, 0), + }; +} + +const okTransport = (): HttpTransport => + vi.fn( + async (_url: string, _options: HttpRequestOptions = {}): Promise => ({ + status: 200, + headers: {}, + body: new TextEncoder().encode(JSON.stringify({ access_token: 'a' })), + }), + ); + +const opts = (over: Partial = {}): BugseeLaunchOptions => ({ + window: fakeTarget().target as unknown as Window, + transport: okTransport(), + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + replay: false, + deviceIdEnv: { + localStorage: { getItem: () => TEST_DEVICE_ID, setItem: () => {}, removeItem: () => {} }, + randomUUID: () => TEST_DEVICE_ID, + }, + ...over, +}); + +// A Web Locks fake that frees a lock when its holder settles (the real contract). +function realLocks() { + const held = new Set(); + 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 }; +} + +// An IDBFactory counting open connections. +function countingIdb(real: 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; + return { idb, open: () => open }; +} + +const live: Bugsee[] = []; +afterEach(async () => { + await Promise.all(live.splice(0).map((c) => c.stop())); + resetBrowserDeviceIdCache(); + vi.unstubAllGlobals(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); +const track = async (token: string, options: BugseeLaunchOptions) => { + const client = await launch(token, options); + live.push(client); + return client; +}; + +describe('status', () => { + it('is Stopped before any launch', () => { + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('is Launching while the async launch is in flight, then Launched, Stopping while draining, Stopped', async () => { + const pending = launchCore('tok', opts()); + expect(getStatus()).toBe(BugseeStatus.Launching); + const { client } = await pending; + live.push(client); + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(client.getStatus()).toBe(BugseeStatus.Launched); + + const stopping = client.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); + await stopping; + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(client.getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('stays Stopping until the capture store has flushed, and a relaunch during it keeps its own status', async () => { + let release!: () => void; + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + store.flush = () => new Promise((r) => (release = r)); + const client = await track('tok', opts({ captureStore: store })); + const stopping = client.stop(); + await vi.waitFor(() => expect(release).toBeDefined()); + expect(getStatus()).toBe(BugseeStatus.Stopping); + + const second = await track('tok', opts()); // launch while the old one is still draining + expect(getStatus()).toBe(BugseeStatus.Launched); + release(); + await stopping; + expect(getStatus()).toBe(BugseeStatus.Launched); // the old stop must not clobber the new client's state + expect(second.getStatus()).toBe(BugseeStatus.Launched); + }); + + it('falls back to Stopped when the launch throws', async () => { + const bad = opts({ persist: true }); + Object.defineProperty(bad, 'indexedDB', { + get(): IDBFactory { + throw new Error('no idb'); + }, + }); + await expect(launchCore('tok', bad)).rejects.toThrow('no idb'); + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('a repeat launch while launched does not disturb the status', async () => { + const first = await track('tok', opts()); + const onError = vi.fn(); + const again = await launch('tok', opts({ onError })); + expect(again).toBe(first); + expect(onError).toHaveBeenCalledTimes(1); + expect(getStatus()).toBe(BugseeStatus.Launched); + }); + + it('stopping a client that no longer owns the slot leaves the newer client and its status alone', async () => { + const stale = await track('tok', opts()); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; // the slot is reset under it + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + store.flush = () => new Promise(() => {}); // newer's stop() never finishes draining + const newer = await track('tok', opts({ captureStore: store })); + void newer.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); + await stale.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); // the stale stop settled without touching it + }); + + it('a second stop() after a relaunch does not evict the newer client', async () => { + const first = await track('tok', opts()); + await first.stop(); + const second = await track('tok', opts()); + await first.stop(); // stale, repeated stop + expect(getCarrier().status).toBe(BugseeStatus.Launched); // the carrier state was not rewritten either + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(await launch('tok', opts({ onError: () => {} }))).toBe(second); // still the singleton + }); +}); + +describe('launch -> stop -> launch cycles leave nothing behind', () => { + it('3 cycles: window/document listeners, the fetch patch, the Web Lock and IDB connections all return to baseline', async () => { + const win = fakeTarget(); + const doc = fakeTarget(); + const originalFetch = vi.fn(async () => new Response('ok')); + vi.stubGlobal('fetch', originalFetch); + const locks = realLocks(); + const real = countingIdb(new IDBFactory()); + const options = (): BugseeLaunchOptions => + opts({ + window: win.target as unknown as Window, + document: doc.target as unknown as Document, + captureNetwork: true, + persist: true, + locks: locks.manager, + indexedDB: real.idb, + }); + + let runningListeners = -1; + for (let cycle = 0; cycle < 3; cycle++) { + const client = await launch('tok', options()); + expect(client.getStatus()).toBe(BugseeStatus.Launched); + expect(globalThis.fetch).not.toBe(originalFetch); // patched exactly once… + await globalThis.fetch('https://example.test/'); + expect(originalFetch).toHaveBeenCalledTimes(cycle + 1); // …so each request reaches the app's fetch once + expect(locks.held.size).toBe(1); + const listeners = win.total() + doc.total(); + expect(listeners).toBeGreaterThan(0); + if (runningListeners === -1) runningListeners = listeners; + expect(listeners).toBe(runningListeners); // no accumulation across cycles + + await client.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(win.total() + doc.total()).toBe(0); + expect(globalThis.fetch).toBe(originalFetch); // patch removed + await vi.waitFor(() => expect(locks.held.size).toBe(0)); + await vi.waitFor(() => expect(real.open()).toBe(0)); + expect(getCarrier().client).toBeUndefined(); + } + }); + + it('stop() with a deadline does not wait out a slow launch-time recovery scan', async () => { + const locks = realLocks(); + const idb = new IDBFactory(); + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + const never = new Promise(() => {}); + // A bundle store whose hydration never completes keeps the launch recovery pending forever. + const bundleStore = { + put: () => {}, + list: () => [], + read: () => undefined, + remove: () => {}, + whenReady: never, + }; + const client = await launch( + 'tok', + opts({ + persist: true, + locks: locks.manager, + indexedDB: idb, + captureStore: store, + bundleStore, + }), + ); + const t0 = Date.now(); + expect(await client.stop(20)).toBe(true); + expect(Date.now() - t0).toBeLessThan(1000); // bounded by the deadline, not by the pending scan + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('a stopped instance is recovered by the NEXT launch as a dead sibling (its lock was released)', async () => { + const locks = realLocks(); + const idb = new IDBFactory(); + const base = { persist: true, locks: locks.manager, indexedDB: idb }; + const first = await launch('tok', opts(base)); + await first.stop(); + const onError = vi.fn(); + const second = await track('tok', opts({ ...base, onError })); + expect(second).not.toBe(first); + expect(onError).not.toHaveBeenCalled(); + expect(locks.held.size).toBe(1); // only the live instance holds a lock + }); + + it('flush failure during stop is routed to onError and does not break the shutdown', async () => { + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + store.flush = () => Promise.reject(new Error('commit failed')); + const onError = vi.fn(); + const client = await launch('tok', opts({ captureStore: store, onError })); + expect(await client.stop()).toBe(true); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'commit failed' })); + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('stop() on a client that is no longer launched (killed) releases the slot and reports Stopped', async () => { + const client = await launch('tok', opts()); + // Simulate the kill-state: the core client is no longer launched but still owns the slot. + vi.spyOn(client, 'isLaunched').mockReturnValue(false); + expect(await client.stop()).toBe(true); + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(getCarrier().client).toBeUndefined(); + }); +}); + +describe('launch options introspection', () => { + it('getLaunchOptions is host over defaults by canonical key; getHostLaunchOptions only what the host set', async () => { + const client = await track('tok', opts({ captureLogs: false, maxRecordingTime: 30 })); + const effective = client.getLaunchOptions(); + expect(effective[BugseeOption.CaptureLogs]).toBe(false); // host value + expect(effective[BugseeOption.Duration]).toBe(30); // host value + expect(effective[BugseeOption.CaptureNetwork]).toBe(false); // the test harness sets captureNetwork: false + expect(effective[BugseeOption.CaptureSystemEvents]).toBe(true); // default + expect(effective[BugseeOption.CaptureViewHierarchy]).toBe(true); // browser default + expect(client.getHostLaunchOptions()).toMatchObject({ + [BugseeOption.CaptureLogs]: false, + [BugseeOption.Duration]: 30, + }); + expect(client.getHostLaunchOptions()).not.toHaveProperty(BugseeOption.CaptureSystemEvents); + }); + + it('never exposes secrets: no token, no transport/headers, only registered canonical keys', async () => { + const client = await track('SECRET-APP-TOKEN', opts({ endpoint: 'https://x.test' })); + for (const view of [client.getLaunchOptions(), client.getHostLaunchOptions()]) { + expect(JSON.stringify(view)).not.toContain('SECRET-APP-TOKEN'); + for (const key of Object.keys(view)) expect(key).toMatch(/^com\.bugsee\.option\./); + } + // Only the harness's own captureNetwork:false is host-supplied; endpoint/transport/token are not options. + expect(Object.keys(client.getHostLaunchOptions())).toEqual([BugseeOption.CaptureNetwork]); + }); + + it('the host options are replaced on each launch, not merged', async () => { + const first = await launch('tok', opts({ captureLogs: false })); + expect(first.getHostLaunchOptions()).toHaveProperty([BugseeOption.CaptureLogs], false); + await first.stop(); + const second = await track('tok', opts()); + expect(second.getHostLaunchOptions()).not.toHaveProperty([BugseeOption.CaptureLogs]); + expect(second.getLaunchOptions()[BugseeOption.CaptureLogs]).toBe(true); + }); +}); + +describe('relaunch', () => { + it('rejects before any launch', async () => { + await expect(relaunch()).rejects.toThrow(/before launch/); + }); + + it('stops the current client, then launches a FRESH one with the same token and options', async () => { + const first = await launch('tok-1', opts({ captureLogs: false })); + const second = await relaunch(); + live.push(second); + expect(second).not.toBe(first); + expect(first.getStatus()).toBe(BugseeStatus.Stopped); + expect(second.getStatus()).toBe(BugseeStatus.Launched); + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(second.getHostLaunchOptions()).toHaveProperty([BugseeOption.CaptureLogs], false); + }); + + it('uses NEW options when given (they replace the previous host options)', async () => { + await launch('tok', opts({ captureLogs: false })); + const second = await relaunch(opts({ maxRecordingTime: 15 })); + live.push(second); + expect(second.getHostLaunchOptions()).not.toHaveProperty([BugseeOption.CaptureLogs]); + expect(second.getLaunchOptions()[BugseeOption.Duration]).toBe(15); + }); + + it('3 relaunches in a row leave exactly one live client and no accumulated listeners', async () => { + const win = fakeTarget(); + await launch('tok', opts({ window: win.target as unknown as Window })); + const baseline = win.total(); + for (let i = 0; i < 3; i++) { + const next = await relaunch(); + live.push(next); + expect(win.total()).toBe(baseline); + } + }); +}); diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index f1677fbd..9158b9cf 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -2080,8 +2080,10 @@ describe('flush on page hide (Wave 6.2)', () => { expect(win.count('pagehide')).toBe(1); await client.stop(); expect(win.count('pagehide')).toBe(0); + // stop() itself commits the store once (the shutdown flush); the hook must add nothing after that. + const flushesAtStop = vi.mocked(captureStore.flush as () => Promise).mock.calls.length; win.emit('pagehide', {}); - expect(captureStore.flush).not.toHaveBeenCalled(); // …and it is genuinely disconnected + expect(captureStore.flush).toHaveBeenCalledTimes(flushesAtStop); // …and it is genuinely disconnected }); it('a failing flush never throws back into the browser’s dispatch', async () => { diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 15caa2e2..98ec2dca 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -21,11 +21,13 @@ import { BUGSEE_SDK_VERSION, type BugseeApi, type BugseeClient, + BugseeStatus, type BundleStore, BundleStoreToken, type CaptureStore, type Clock, COMMON_OPTION_DEFINITIONS, + clearCarrierClient, createBugseeApi, createBundleUploader, createClient, @@ -36,17 +38,22 @@ import { createUploadPipeline, defineService, getCarrierClient, + getCarrierStatus, getOrCreateInterceptor, + getProcessStatus, getServiceManifests, type HttpRequestOptions, type HttpResponse, type HttpTransport, ReportMarkerStoreToken, recoverReports, + relaunchWith, resolveLaunchOptions, runLaunchRecovery, type Scheduler, setCarrierClient, + setCarrierLastLaunch, + setCarrierStatus, TransportToken, type TriggerPipeline, type UploadResult, @@ -357,9 +364,36 @@ const internalTagged = headers: { ...options.headers, 'x-bugsee-internal': '1' }, }); +// The public composition root's lifecycle shell: publishes Launching while the (async) composition is in +// flight, Launched once it has built a client, and falls back to Stopped if it throws. A repeat launch +// (a client already owns the slot) leaves the state alone. export async function launchCore( appToken: string, options: BugseeLaunchOptions = {}, +): Promise { + const carrier = options.carrier; + const owned = getCarrierClient(carrier) === undefined; + if (owned) { + setCarrierStatus(BugseeStatus.Launching, carrier); + } + try { + const result = await buildLaunch(appToken, options); + if (result.internals !== undefined) { + setCarrierLastLaunch(appToken, options, carrier); + setCarrierStatus(BugseeStatus.Launched, carrier); + } + return result; + } catch (error) { + if (owned && getCarrierClient(carrier) === undefined) { + setCarrierStatus(BugseeStatus.Stopped, carrier); + } + throw error; + } +} + +async function buildLaunch( + appToken: string, + options: BugseeLaunchOptions = {}, ): Promise { const sdkVersion = options.sdkVersion ?? SDK_VERSION; const baseUrl = options.endpoint ?? DEFAULT_ENDPOINT; @@ -520,6 +554,7 @@ export async function launchCore( const client = createClient({ isEnabled: resolved.isEnabled, launchOptions: resolved.options, + launchOptionsView: { effective: resolved.canonical, host: resolved.host }, services, uploadPipeline, appToken, @@ -665,7 +700,7 @@ export async function launchCore( // incidents its bundle-queue leg already settled with (`skipReportIds`), so one incident is delivered // once even when the sibling left BOTH a staged bundle and that incident's marker (the SEV1 // double-upload — reconciled in browser-utils). - void runLaunchRecovery({ + const recoveryDone = runLaunchRecovery({ ...(durable !== undefined ? { queue: durable } : {}), // An explicit `bundleStore` bypasses coexistence: it is the integrator's own and stable across // launches, so the dead-sibling scan must get first refusal on it (see the option's docs in core). @@ -680,12 +715,13 @@ export async function launchCore( ...(recoverEnabled ? { recoverReportsForSibling: async ({ captureView, markers, skipReportIds }) => { + const backend = createIdbChunkBackend(captureView, { + generation: -1, + cleanOtherGenerations: false, + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }); await recoverReports({ - backend: createIdbChunkBackend(captureView, { - generation: -1, - cleanOtherGenerations: false, - ...(options.onError !== undefined ? { onError: options.onError } : {}), - }), + backend, currentGeneration: -1, markers, context: () => ({ appToken, environment: getEnvironment(), clock }), @@ -693,6 +729,10 @@ export async function launchCore( skipReportIds, ...(options.onError !== undefined ? { onError: options.onError } : {}), }); + // recoverReports deletes the sibling's generations through the backend's write QUEUE, which + // it does not await. Settle it here so the cleanup lands before this scan counts as done + // (and before a stop() closes the connection under it). + await backend.flush?.(); }, } : {}), @@ -762,10 +802,41 @@ export async function launchCore( // Android applies `ReportingDefaultBugPriority` to every bug report, not only the dialog's. upload: (uploadOptions) => client.upload({ ...uploadOptions, severity: uploadOptions?.severity ?? defaultSeverity }), - stop(timeout?: number): Promise { - setCarrierClient(undefined, carrier); + async stop(timeout?: number): Promise { + const wasLaunched = client.isLaunched(); + // Release the process slot, but only if it is still OURS: a repeat/late stop() must not evict a + // client that has since been relaunched. + const owned = getCarrierClient(carrier) === publicClient; + if (owned) { + setCarrierStatus(wasLaunched ? BugseeStatus.Stopping : BugseeStatus.Stopped, carrier); + clearCarrierClient(publicClient, carrier); + } uninstallPageHideFlush(); - return stopCore(timeout); + if (!wasLaunched) { + return stopCore(timeout); + } + try { + return await stopCore(timeout); + } finally { + // Everything below is idempotent; the drain is over, so now make the data durable, then let go of + // the instance liveness lock (this instance now reads as dead, so the NEXT launch can recover any + // leftovers) and the IndexedDB connections. + try { + await captureStore.flush?.(); + } catch (error) { + options.onError?.(error); + } + // The launch-time dead-sibling scan may still be reading IndexedDB (a quick launch -> stop); closing + // under it would just make it reopen the connection and leak it. Let it settle first — bounded by + // the caller's own timeout, since a long recovery upload must not hold up a deadline-bound stop. + await (timeout === undefined + ? recoveryDone + : Promise.race([recoveryDone, new Promise((r) => setTimeout(r, timeout))])); + coexistence.release(); + if (owned && getCarrierStatus(carrier) === BugseeStatus.Stopping) { + setCarrierStatus(BugseeStatus.Stopped, carrier); + } + } }, }; @@ -788,6 +859,18 @@ export async function launchCore( return { client: publicClient, internals }; } +/** The process lifecycle state (Stopped / Launching / Launched / Stopping), also while no client exists. + * `carrier` is the host the launch used (default `globalThis`). */ +export function getStatus(carrier?: object): BugseeStatus { + return getProcessStatus(carrier); +} + +/** Stop the running client (if any) and launch again with the same token; `options` replace the previous + * launch options when given. Uses the process carrier of `options.carrier` (default `globalThis`). */ +export function relaunch(options?: BugseeLaunchOptions): Promise { + return relaunchWith(launch, options, options?.carrier); +} + // The public composition root: the launched client. Equivalent to `(await launchCore(...)).client` — // `launchCore` additionally surfaces the internal wiring (`LaunchInternals`) that the `bugsee` umbrella // uses to wire on-by-default extensions; bare `@bugsee/browser` callers use this and never see the internals. diff --git a/packages/bugsee/src/bun.ts b/packages/bugsee/src/bun.ts index 72fd6099..ef61479e 100644 --- a/packages/bugsee/src/bun.ts +++ b/packages/bugsee/src/bun.ts @@ -1,4 +1,5 @@ import { launchCore } from '@bugsee/bun'; +import { relaunchWith } from '@bugsee/core'; import type { Bugsee } from '@bugsee/node'; import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; @@ -14,3 +15,8 @@ export type BugseeBunLaunchOptions = BugseeServerLaunchOptions; export const launch: (appToken: string, options?: BugseeBunLaunchOptions) => Bugsee = createServerLaunch(launchCore); + +/** Stop the running client (if any) and launch again with the same token, re-wiring the on-by-default + * extensions; `options` replace the previous launch options when given. */ +export const relaunch = (options?: BugseeBunLaunchOptions): Promise => + relaunchWith(launch, options, options?.carrier); diff --git a/packages/bugsee/src/deno.ts b/packages/bugsee/src/deno.ts index 69370681..11376856 100644 --- a/packages/bugsee/src/deno.ts +++ b/packages/bugsee/src/deno.ts @@ -1,3 +1,4 @@ +import { relaunchWith } from '@bugsee/core'; import { launchCore } from '@bugsee/deno'; import type { Bugsee } from '@bugsee/node'; import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; @@ -13,3 +14,8 @@ export type BugseeDenoLaunchOptions = BugseeServerLaunchOptions; export const launch: (appToken: string, options?: BugseeDenoLaunchOptions) => Bugsee = createServerLaunch(launchCore); + +/** Stop the running client (if any) and launch again with the same token, re-wiring the on-by-default + * extensions; `options` replace the previous launch options when given. */ +export const relaunch = (options?: BugseeDenoLaunchOptions): Promise => + relaunchWith(launch, options, options?.carrier); diff --git a/packages/bugsee/src/index.bun.ts b/packages/bugsee/src/index.bun.ts index a3d7cf68..813a8513 100644 --- a/packages/bugsee/src/index.bun.ts +++ b/packages/bugsee/src/index.bun.ts @@ -13,4 +13,5 @@ export type { UploadOptions, } from '@bugsee/core'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; -export { type BugseeBunLaunchOptions, launch } from './bun'; +export { type BugseeBunLaunchOptions, launch, relaunch } from './bun'; +export { BugseeStatus, getStatus } from './status'; diff --git a/packages/bugsee/src/index.deno.ts b/packages/bugsee/src/index.deno.ts index e4b8dad9..aba0a3e1 100644 --- a/packages/bugsee/src/index.deno.ts +++ b/packages/bugsee/src/index.deno.ts @@ -13,4 +13,5 @@ export type { } from '@bugsee/core'; export type { Bugsee } from '@bugsee/deno'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; -export { type BugseeDenoLaunchOptions, launch } from './deno'; +export { type BugseeDenoLaunchOptions, launch, relaunch } from './deno'; +export { BugseeStatus, getStatus } from './status'; diff --git a/packages/bugsee/src/index.node.ts b/packages/bugsee/src/index.node.ts index e5205bb4..306857ca 100644 --- a/packages/bugsee/src/index.node.ts +++ b/packages/bugsee/src/index.node.ts @@ -20,4 +20,5 @@ export type { Bugsee, HttpRequestOptions, HttpResponse, HttpTransport } from '@b // to reach this token, as every framework adapter's own hooks.ts already does internally. export { type RequestContextStore, RequestContextStoreToken } from '@bugsee/node'; export type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; -export { type BugseeNodeLaunchOptions, launch } from './node'; +export { type BugseeNodeLaunchOptions, launch, relaunch } from './node'; +export { BugseeStatus, getStatus } from './status'; diff --git a/packages/bugsee/src/index.ts b/packages/bugsee/src/index.ts index 029a896e..28eb31f3 100644 --- a/packages/bugsee/src/index.ts +++ b/packages/bugsee/src/index.ts @@ -28,4 +28,5 @@ export type { Transaction, TransactionNameSource, } from '@bugsee/performance'; -export { type BugseeLaunchOptionsWithPerformance, launch } from './launch'; +export { type BugseeLaunchOptionsWithPerformance, launch, relaunch } from './launch'; +export { BugseeStatus, getStatus } from './status'; diff --git a/packages/bugsee/src/launch.test.ts b/packages/bugsee/src/launch.test.ts index 50dd9e43..8094182a 100644 --- a/packages/bugsee/src/launch.test.ts +++ b/packages/bugsee/src/launch.test.ts @@ -4,7 +4,8 @@ import type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/co import type { BugseeSpanProcessor } from '@bugsee/opentelemetry'; import { serializeTransaction, type Transaction } from '@bugsee/performance'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type BugseeLaunchOptionsWithPerformance, launch } from './launch'; +import { type BugseeLaunchOptionsWithPerformance, launch, relaunch } from './launch'; +import { BugseeStatus, getStatus } from './status'; // --- fakes --------------------------------------------------------------------------------------- @@ -625,3 +626,29 @@ describe('bugsee umbrella launch', () => { expect(client).toBeDefined(); }); }); + +describe('umbrella relaunch / getStatus (browser)', () => { + it('relaunch stops the old client and returns a fresh one with the extensions re-wired', async () => { + const first = await track(launch('tok', base())); + expect(getStatus()).toBe(BugseeStatus.Launched); + const second = await track(relaunch()); + expect(second).not.toBe(first); + expect(first.getStatus()).toBe(BugseeStatus.Stopped); + expect(second.getStatus()).toBe(BugseeStatus.Launched); + expect(() => second.ext('performance')).not.toThrow(); // the on-by-default extension is wired again + expect(getStatus()).toBe(BugseeStatus.Launched); + }); + + it('relaunch with new options replaces the previous host options', async () => { + await track(launch('tok', base({ captureLogs: false }))); + const second = await track(relaunch(base({ maxRecordingTime: 20 }))); + expect(second.getHostLaunchOptions()).not.toHaveProperty(['com.bugsee.option.capture.logs']); + expect(second.getLaunchOptions()['com.bugsee.option.config.duration']).toBe(20); + }); + + it('getStatus reports Stopped once the client has been stopped', async () => { + const client = await track(launch('tok', base())); + await client.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); +}); diff --git a/packages/bugsee/src/launch.ts b/packages/bugsee/src/launch.ts index 2b64590a..dbfb34de 100644 --- a/packages/bugsee/src/launch.ts +++ b/packages/bugsee/src/launch.ts @@ -6,6 +6,7 @@ import { launchCore, readMetaTraceContinuation, } from '@bugsee/browser'; +import { relaunchWith } from '@bugsee/core'; import { type UmbrellaExtensionOptions, wireUmbrella } from './wire'; // The `bugsee` umbrella launch() for the BROWSER — the batteries-included entry. It runs the browser @@ -40,3 +41,9 @@ export async function launch( }, ); } + +/** Stop the running client (if any) and launch again with the same token, re-wiring the on-by-default + * extensions; `options` replace the previous launch options when given. */ +export function relaunch(options?: BugseeLaunchOptionsWithPerformance): Promise { + return relaunchWith(launch, options, options?.carrier); +} diff --git a/packages/bugsee/src/node.ts b/packages/bugsee/src/node.ts index 242f5c3d..9d370f6e 100644 --- a/packages/bugsee/src/node.ts +++ b/packages/bugsee/src/node.ts @@ -1,3 +1,4 @@ +import { relaunchWith } from '@bugsee/core'; import type { Bugsee } from '@bugsee/node'; import { launchCore } from '@bugsee/node'; import { type BugseeServerLaunchOptions, createServerLaunch } from './server-launch'; @@ -10,3 +11,8 @@ export type BugseeNodeLaunchOptions = BugseeServerLaunchOptions; export const launch: (appToken: string, options?: BugseeNodeLaunchOptions) => Bugsee = createServerLaunch(launchCore); + +/** Stop the running client (if any) and launch again with the same token, re-wiring the on-by-default + * extensions; `options` replace the previous launch options when given. */ +export const relaunch = (options?: BugseeNodeLaunchOptions): Promise => + relaunchWith(launch, options, options?.carrier); diff --git a/packages/bugsee/src/server-entries.test.ts b/packages/bugsee/src/server-entries.test.ts index 2b4b3f75..17c8aefd 100644 --- a/packages/bugsee/src/server-entries.test.ts +++ b/packages/bugsee/src/server-entries.test.ts @@ -1,9 +1,10 @@ import type { Clock, HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/core'; import type { Bugsee, NodeRuntime } from '@bugsee/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { launch as launchBun } from './bun'; -import { launch as launchDeno } from './deno'; -import { launch as launchNode } from './node'; +import { launch as launchBun, relaunch as relaunchBun } from './bun'; +import { launch as launchDeno, relaunch as relaunchDeno } from './deno'; +import { launch as launchNode, relaunch as relaunchNode } from './node'; +import { BugseeStatus, getStatus } from './status'; // WAVE 3b.1 / 4.1 — the umbrella's three SERVER entries. // @@ -116,3 +117,24 @@ describe('the umbrella server entries bind their OWN runtime', () => { } }); }); + +describe('the umbrella server entries relaunch through their OWN composition root', () => { + it.each([ + ['node', launchNode, relaunchNode], + ['bun', launchBun, relaunchBun], + ['deno', launchDeno, relaunchDeno], + ] as const)('%s: relaunch returns a fresh client of the same runtime and status follows', async (type, launch, relaunch) => { + const { fn, sessions } = recordingTransport(); + const carrier = {}; + const options = { ...base(fn), carrier }; + const first = track(launch('tok', options)); + expect(getStatus(carrier)).toBe(BugseeStatus.Launched); + const second = track(await relaunch(options)); + expect(second).not.toBe(first); + expect(first.getStatus()).toBe(BugseeStatus.Stopped); + expect(getStatus(carrier)).toBe(BugseeStatus.Launched); + second.logException(new Error('x')); + await second.flush(5000); + expect(sessions.at(-1)?.runtime.type).toBe(type); // the relaunch ran this entry's composition root + }); +}); diff --git a/packages/bugsee/src/status.ts b/packages/bugsee/src/status.ts new file mode 100644 index 00000000..06a5001f --- /dev/null +++ b/packages/bugsee/src/status.ts @@ -0,0 +1,4 @@ +// The lifecycle surface shared by every umbrella entry: the status enum and the process-level getStatus(). +// `getStatus()` reads the process state, so it answers Launching / Stopping too — moments when no client +// object is reachable yet (or any more). Re-exported from core so the umbrella adds no logic of its own. +export { BugseeStatus, getProcessStatus as getStatus } from '@bugsee/core'; diff --git a/packages/core/src/carrier.test.ts b/packages/core/src/carrier.test.ts index 2b7d1dd0..0090e268 100644 --- a/packages/core/src/carrier.test.ts +++ b/packages/core/src/carrier.test.ts @@ -2,17 +2,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { BUGSEE_SDK_VERSION, type BugseeCarrier, + clearCarrierClient, contributeServiceManifest, getCarrier, getCarrierClient, + getCarrierStatus, getFilters, getInternal, getOrCreateInterceptor, + getProcessStatus, getServiceManifests, type ServiceManifest, setCarrierClient, + setCarrierLastLaunch, + setCarrierStatus, } from './carrier'; import type { Interceptor } from './contracts'; +import { BugseeStatus } from './launch-status'; // A minimal Interceptor stand-in: the carrier only stores/returns the instance (never calls its // methods), so identity is all that matters. @@ -139,6 +145,85 @@ describe('getCarrierClient / setCarrierClient (per-process singleton slot)', () }); }); +describe('clearCarrierClient (release only your own slot)', () => { + it('clears the slot when it still holds the given client', () => { + const g = {}; + const client = { id: 'a' }; + setCarrierClient(client, g); + clearCarrierClient(client, g); + expect(getCarrierClient(g)).toBeUndefined(); + }); + + it('leaves a NEWER client in the slot (a stale stop() must not evict a relaunch)', () => { + const g = {}; + const oldClient = { id: 'old' }; + const newClient = { id: 'new' }; + setCarrierClient(newClient, g); + clearCarrierClient(oldClient, g); + expect(getCarrierClient(g)).toBe(newClient); + }); + + it('defaults to the real globalThis', () => { + const client = { id: 'g' }; + setCarrierClient(client); + clearCarrierClient(client); + expect(getCarrierClient()).toBeUndefined(); + }); +}); + +describe('getCarrierStatus / setCarrierStatus (process lifecycle state)', () => { + it('is Stopped until set, and stores each status', () => { + const g = {}; + expect(getCarrierStatus(g)).toBe(BugseeStatus.Stopped); + setCarrierStatus(BugseeStatus.Launching, g); + expect(getCarrierStatus(g)).toBe(BugseeStatus.Launching); + setCarrierStatus(BugseeStatus.Launched, g); + expect(getCarrier(g).status).toBe(BugseeStatus.Launched); + }); + + it('defaults to the real globalThis', () => { + setCarrierStatus(BugseeStatus.Stopping); + expect(getCarrierStatus()).toBe(BugseeStatus.Stopping); + }); +}); + +describe('getProcessStatus', () => { + it("reports the launched client's own status over the carrier's", () => { + const g = {}; + setCarrierStatus(BugseeStatus.Launching, g); + setCarrierClient({ getStatus: () => BugseeStatus.Stopped }, g); // e.g. a killed SDK + expect(getProcessStatus(g)).toBe(BugseeStatus.Stopped); + setCarrierClient({ getStatus: () => BugseeStatus.Launched }, g); + expect(getProcessStatus(g)).toBe(BugseeStatus.Launched); + }); + + it("falls back to the carrier's status with no client (Launching / Stopping / Stopped)", () => { + const g = {}; + expect(getProcessStatus(g)).toBe(BugseeStatus.Stopped); + setCarrierStatus(BugseeStatus.Launching, g); + expect(getProcessStatus(g)).toBe(BugseeStatus.Launching); + }); + + it('defaults to the real globalThis', () => { + setCarrierStatus(BugseeStatus.Stopping); + expect(getProcessStatus()).toBe(BugseeStatus.Stopping); + }); +}); + +describe('setCarrierLastLaunch', () => { + it('stores the token and options on the carrier slot', () => { + const g = {}; + const options = { a: 1 }; + setCarrierLastLaunch('tok', options, g); + expect(getCarrier(g).lastLaunch).toEqual({ appToken: 'tok', options }); + }); + + it('defaults to the real globalThis', () => { + setCarrierLastLaunch('tok2', undefined); + expect(getCarrier().lastLaunch?.appToken).toBe('tok2'); + }); +}); + describe('getInternal (the singleton client as a service resolver)', () => { it('is undefined when no client is launched', () => { expect(getInternal({})).toBeUndefined(); diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 30af4c29..3ce78ce9 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -1,5 +1,6 @@ import type { Interceptor } from './contracts'; import { type FilterStore, FiltersToken } from './filters'; +import { BugseeStatus } from './launch-status'; import type { ServiceRegistrar, ServiceResolver } from './services'; /** @@ -35,6 +36,10 @@ export interface BugseeCarrier { readonly interceptors: Map>; /** The process-global launched client (Bugsee is a per-process singleton, §1497/§473); else undefined. */ client?: unknown; + /** The process lifecycle state; absent means {@link BugseeStatus.Stopped}. */ + status?: BugseeStatus; + /** The arguments of the last launch that built a client — what `relaunch()` reuses (token + options). */ + lastLaunch?: { appToken: string; options: unknown }; /** Service manifests contributed by extensions / other packages; run against the launched container. */ serviceManifests?: ServiceManifest[]; } @@ -115,6 +120,46 @@ export function setCarrierClient(client: unknown, globalObj: object = globalThis getCarrier(globalObj).client = client; } +/** + * Clear the process-global client slot, but ONLY if it still holds `client`. A stale `stop()` (called + * twice, or after a relaunch) must not evict the newer client that has since taken the slot. + */ +export function clearCarrierClient(client: unknown, globalObj: object = globalThis): void { + const carrier = getCarrier(globalObj); + if (carrier.client === client) { + carrier.client = undefined; + } +} + +/** The process lifecycle state (Stopped until a launch begins). */ +export function getCarrierStatus(globalObj: object = globalThis): BugseeStatus { + return getCarrier(globalObj).status ?? BugseeStatus.Stopped; +} + +/** Record the process lifecycle state. */ +export function setCarrierStatus(status: BugseeStatus, globalObj: object = globalThis): void { + getCarrier(globalObj).status = status; +} + +/** + * The process lifecycle state a top-level `getStatus()` reports: the launched client's own state when + * there is one (so a killed SDK reads Stopped), else the carrier's (Launching during an async launch, + * Stopping while the previous client drains, Stopped otherwise). + */ +export function getProcessStatus(globalObj: object = globalThis): BugseeStatus { + const client = getCarrierClient<{ getStatus(): BugseeStatus }>(globalObj); + return client !== undefined ? client.getStatus() : getCarrierStatus(globalObj); +} + +/** Remember the arguments of a launch that built a client, for `relaunch()`. */ +export function setCarrierLastLaunch( + appToken: string, + options: unknown, + globalObj: object = globalThis, +): void { + getCarrier(globalObj).lastLaunch = { appToken, options }; +} + /** * The internal aggregated object (the singleton client's service resolver) for in-process consumers — * e.g. the capture pipeline resolving services without importing the Client. Undefined until launched. diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 411f7b90..f494fd8b 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -20,6 +20,7 @@ import { } from './durable-upload-pipeline'; import { BugseeError } from './errors'; import { type FilterableSpan, FiltersToken } from './filters'; +import { BugseeStatus } from './launch-status'; import { createMemoryCaptureStore } from './memory-capture-store'; import { createOptionsContainer } from './options'; import { createPartitionedCaptureStore } from './partitioned-capture-store'; @@ -1316,6 +1317,45 @@ describe('createClient — lifecycle', () => { expect(await createClient().flush()).toBe(true); }); + it('getStatus walks Stopped -> Launched -> Stopping -> Stopped around a draining stop', async () => { + let release!: () => void; + const flush = vi.fn(() => new Promise((r) => (release = () => r(true)))); + const client = createClient({ uploadPipeline: { flush } as unknown as UploadPipeline }); + expect(client.getStatus()).toBe(BugseeStatus.Stopped); + client.launch(); + expect(client.getStatus()).toBe(BugseeStatus.Launched); + const stopping = client.stop(); + expect(client.getStatus()).toBe(BugseeStatus.Stopping); + await vi.waitFor(() => expect(flush).toHaveBeenCalled()); + expect(client.getStatus()).toBe(BugseeStatus.Stopping); // still draining + release(); + await stopping; + expect(client.getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('a stop whose drain times out still ends Stopped', async () => { + const flush = vi.fn(() => new Promise(() => {})); + const client = createClient({ uploadPipeline: { flush } as unknown as UploadPipeline }); + client.launch(); + expect(await client.stop(5)).toBe(false); + expect(client.getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('getLaunchOptions / getHostLaunchOptions return copies of the supplied view (empty by default)', () => { + expect(createClient().getLaunchOptions()).toEqual({}); + expect(createClient().getHostLaunchOptions()).toEqual({}); + const effective = { a: 1, b: 2 }; + const host = { b: 2 }; + const client = createClient({ launchOptionsView: { effective, host } }); + const got = client.getLaunchOptions(); + expect(got).toEqual(effective); + expect(got).not.toBe(effective); + got.a = 99; + expect(client.getLaunchOptions()).toEqual({ a: 1, b: 2 }); + expect(client.getHostLaunchOptions()).toEqual({ b: 2 }); + expect(client.getHostLaunchOptions()).not.toBe(host); + }); + it('can relaunch after stop', () => { const client = createClient(); const provider = gatedCaptureProvider('net'); @@ -1707,6 +1747,7 @@ describe('createClient — kill-state (collector KILL_SDK)', () => { expect(onError).toHaveBeenCalledWith(err); expect(client.isLaunched()).toBe(false); // capture/detection halted + expect(client.getStatus()).toBe(BugseeStatus.Stopped); // a killed SDK is not 'Launched' expect(provider.stop).toHaveBeenCalled(); // the capture coordinator was stopped // Subsequent captures are no-ops. client.log('after-kill'); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..223db191 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -57,6 +57,7 @@ import { callSiteFrames, parseV8Stack, type StackFrame } from './stack'; export type { Breadcrumb, BreadcrumbInput } from './events'; import { createExtensionRegistry } from './extension-registry'; +import { BugseeStatus } from './launch-status'; import { createMemoryCaptureStore } from './memory-capture-store'; import { createOperationDispatcher } from './operation-dispatcher'; import { createOptionsContainer } from './options'; @@ -242,6 +243,12 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar /** Start capture + detection. Idempotent — a second call while launched is ignored. */ launch(): void; isLaunched(): boolean; + /** The lifecycle state (Stopped / Launching / Launched / Stopping). `Stopping` lasts until the drain settles. */ + getStatus(): BugseeStatus; + /** The effective launch options (host-supplied over defaults), by canonical `com.bugsee.option.*` key. */ + getLaunchOptions(): Record; + /** Only the options the host supplied at launch, by canonical key. */ + getHostLaunchOptions(): Record; /** Stop capture + detection and drain pending uploads. Resolves true if drained within `timeout`. */ stop(timeout?: number): Promise; /** Drain pending uploads. Resolves true if drained within `timeout`. */ @@ -300,6 +307,14 @@ export interface CreateClientOptions { isEnabled?: OptionGate; /** Launch options passed to each provider's start(options) for per-launch reconfiguration. */ launchOptions?: OptionsContainer; + /** + * The introspectable form of the launch options for `getLaunchOptions()` / `getHostLaunchOptions()` + * (canonical keys). Never put secrets in here — the platform passes only registered option definitions. + */ + launchOptionsView?: { + effective: Readonly>; + host: Readonly>; + }; /** Capture-storm rate limit (§7.7). Default 100 / 60s. */ captureRateLimit?: RateLimiterOptions; /** Upload pipeline (built by the platform from BugseeApi/BundleUploader); enables flush/stop drain. */ @@ -438,6 +453,8 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { // True once stop() has run (until a re-launch): manual captures that upload become no-ops (§1501). // Distinct from `!launched` so capturing BEFORE the first launch is unaffected. let stopped = false; + // True from stop() until its upload drain settles (BugseeStatus.Stopping). + let stopping = false; // Permanent kill-state (§1435/§1504): set when a report comes back with the collector's KILL_SDK // verdict (code 99099) — the app token has been switched OFF at the server. All capture goes no-op, // capture+detection halt, onError fires ONCE. An INVALID app token is a different thing entirely: it @@ -951,6 +968,21 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { return launched; }, + getStatus(): BugseeStatus { + if (stopping) { + return BugseeStatus.Stopping; + } + return launched ? BugseeStatus.Launched : BugseeStatus.Stopped; + }, + + getLaunchOptions(): Record { + return { ...options.launchOptionsView?.effective }; + }, + + getHostLaunchOptions(): Record { + return { ...options.launchOptionsView?.host }; + }, + launch(): void { // A killed client (collector KILL_SDK) is permanently dead: re-launching must not re-arm it. if (killed || launched) { @@ -994,7 +1026,10 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { launched = false; stopped = true; haltCapture(); // launch() always sets tickTimer first, so it is set here (stop runs only if launched) - return drainPending(timeout); + stopping = true; + return drainPending(timeout).finally(() => { + stopping = false; + }); }, flush(timeout?: number): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..5a9912f6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,15 +38,20 @@ export { export { BUGSEE_SDK_VERSION, type BugseeCarrier, + clearCarrierClient, contributeServiceManifest, getCarrier, getCarrierClient, + getCarrierStatus, getFilters, getInternal, getOrCreateInterceptor, + getProcessStatus, getServiceManifests, type ServiceManifest, setCarrierClient, + setCarrierLastLaunch, + setCarrierStatus, } from './carrier'; export type { ChunkBackend, FrozenPart, PartMeta, PartRef } from './chunk-backend'; export { type ChunkCaptureStoreOptions, createChunkCaptureStore } from './chunk-capture-store'; @@ -158,6 +163,7 @@ export { type ReconcileOwnQueue, runLaunchRecovery, } from './launch-recovery'; +export { BugseeStatus } from './launch-status'; export { createMemoryCaptureStore, type MemoryCaptureStoreOptions, @@ -191,6 +197,7 @@ export { type RateLimiter, type RateLimiterOptions, } from './rate-limiter'; +export { relaunchWith } from './relaunch'; export { type ReportMarker, type ReportMarkerStore, diff --git a/packages/core/src/launch-status.test.ts b/packages/core/src/launch-status.test.ts new file mode 100644 index 00000000..5f1bb0e9 --- /dev/null +++ b/packages/core/src/launch-status.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { BugseeStatus } from './launch-status'; + +describe('BugseeStatus', () => { + it('uses the mobile numeric values (BugseeStatus / Bugsee.Status)', () => { + expect(BugseeStatus.Stopped).toBe(0); + expect(BugseeStatus.Launching).toBe(1); + expect(BugseeStatus.Launched).toBe(2); + expect(BugseeStatus.Stopping).toBe(3); + }); +}); diff --git a/packages/core/src/launch-status.ts b/packages/core/src/launch-status.ts new file mode 100644 index 00000000..ab7c52ba --- /dev/null +++ b/packages/core/src/launch-status.ts @@ -0,0 +1,12 @@ +// The SDK lifecycle state (mobile parity: iOS `BugseeStatus`, Android `Bugsee.Status`). Numeric values +// match the mobile enums so a value read on the wire or from a bridge means the same thing everywhere. +// `Launching` is observable only while an ASYNC launch (the browser's lazy-loaded composition) is in +// flight; `Stopping` while `stop()` is draining pending uploads. +export const BugseeStatus = { + Stopped: 0, + Launching: 1, + Launched: 2, + Stopping: 3, +} as const; + +export type BugseeStatus = (typeof BugseeStatus)[keyof typeof BugseeStatus]; diff --git a/packages/core/src/options.test.ts b/packages/core/src/options.test.ts index 11b48569..e00f3d89 100644 --- a/packages/core/src/options.test.ts +++ b/packages/core/src/options.test.ts @@ -64,6 +64,15 @@ describe('resolveLaunchOptions', () => { }); }); + it('reports only the host-supplied options, by canonical key (no defaults, no unknown names)', () => { + const r = resolveLaunchOptions( + { captureNetwork: false, appToken: 'secret', transport: 1 }, + defs, + ); + expect(r.host).toEqual({ 'com.bugsee.option.capture.network': false }); + expect(resolveLaunchOptions({}, defs).host).toEqual({}); + }); + it('applies all defaults for an empty bag', () => { expect(resolveLaunchOptions({}, defs).canonical).toEqual({ 'com.bugsee.option.capture.network': true, diff --git a/packages/core/src/options.ts b/packages/core/src/options.ts index 8b8f4044..84fde67b 100644 --- a/packages/core/src/options.ts +++ b/packages/core/src/options.ts @@ -36,6 +36,8 @@ export interface OptionDefinition { export interface ResolvedLaunchOptions { /** Canonical identifier → value (defaults applied). Serialize via optionsToWire for the wire. */ readonly canonical: Record; + /** ONLY the options the host supplied, by canonical identifier (no defaults, no unknown names). */ + readonly host: Record; /** Launch options keyed by canonical identifier, passed to each provider's start(). */ readonly options: OptionsContainer; /** A boolean option is enabled unless explicitly set to false (an unknown key defaults enabled). */ @@ -48,11 +50,18 @@ export function resolveLaunchOptions( definitions: readonly OptionDefinition[], ): ResolvedLaunchOptions { const canonical: Record = {}; + const host: Record = {}; for (const def of definitions) { - canonical[def.key] = Object.hasOwn(values, def.friendly) ? values[def.friendly] : def.default; + if (Object.hasOwn(values, def.friendly)) { + host[def.key] = values[def.friendly]; + canonical[def.key] = values[def.friendly]; + } else { + canonical[def.key] = def.default; + } } return { canonical, + host, options: createOptionsContainer(canonical), isEnabled: (key) => canonical[key] !== false, }; diff --git a/packages/core/src/relaunch.test.ts b/packages/core/src/relaunch.test.ts new file mode 100644 index 00000000..8a243616 --- /dev/null +++ b/packages/core/src/relaunch.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCarrier, setCarrierClient, setCarrierLastLaunch } from './carrier'; +import { relaunchWith } from './relaunch'; + +describe('relaunchWith', () => { + it('rejects when nothing was ever launched', async () => { + const launch = vi.fn(); + await expect(relaunchWith(launch, undefined, {})).rejects.toThrow(/before launch/); + expect(launch).not.toHaveBeenCalled(); + }); + + it('stops the running client FIRST, then launches with the same token and the previous options', async () => { + const g = {}; + const order: string[] = []; + const previous = { stop: vi.fn(async () => void order.push('stop')) }; + setCarrierClient(previous, g); + setCarrierLastLaunch('tok', { endpoint: 'x' }, g); + const next = { stop: vi.fn() }; + const launch = vi.fn(async () => { + order.push('launch'); + return next; + }); + const result = await relaunchWith(launch, undefined, g); + expect(result).toBe(next); + expect(order).toEqual(['stop', 'launch']); + expect(launch).toHaveBeenCalledWith('tok', { endpoint: 'x' }); + }); + + it('uses NEW options instead of the previous ones when given, and works with no client running', async () => { + const g = {}; + setCarrierLastLaunch('tok', { endpoint: 'old' }, g); + const next = { stop: vi.fn() }; + const launch = vi.fn(() => next); + expect(await relaunchWith(launch, { endpoint: 'new' }, g)).toBe(next); + expect(launch).toHaveBeenCalledWith('tok', { endpoint: 'new' }); + expect(getCarrier(g).lastLaunch?.options).toEqual({ endpoint: 'old' }); // recording is the launcher's job + }); + + it('defaults to the real globalThis carrier', async () => { + setCarrierLastLaunch('g-tok', { a: 1 }); + const launch = vi.fn(() => ({ stop: vi.fn() })); + await relaunchWith(launch, undefined); + expect(launch).toHaveBeenCalledWith('g-tok', { a: 1 }); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; + }); +}); diff --git a/packages/core/src/relaunch.ts b/packages/core/src/relaunch.ts new file mode 100644 index 00000000..44ee0cbe --- /dev/null +++ b/packages/core/src/relaunch.ts @@ -0,0 +1,27 @@ +import { getCarrier, getCarrierClient } from './carrier'; + +/** + * Stop the running client (if any) and launch again with the same app token (Android/iOS `relaunch`). + * + * `launch` is the PLATFORM's launch function (so the umbrella's extensions are re-wired too). `options` + * REPLACE the previous launch options when given; omitted, the previous ones are reused. The previous + * client is stopped and fully drained first, so the new client starts with nothing left over. + * + * `globalObj` is the carrier host the launch used (injectable for tests; default `globalThis`). + * Rejects when nothing has been launched yet — there is no token to reuse. + */ +export async function relaunchWith< + Options, + Client extends { stop(timeout?: number): Promise }, +>( + launch: (appToken: string, options: Options) => Client | Promise, + options: Options | undefined, + globalObj: object = globalThis, +): Promise { + const last = getCarrier(globalObj).lastLaunch; + if (last === undefined) { + throw new Error('Bugsee.relaunch() called before launch(); there is no app token to reuse'); + } + await getCarrierClient(globalObj)?.stop(); + return launch(last.appToken, options ?? (last.options as Options)); +} diff --git a/packages/node/src/index.test.ts b/packages/node/src/index.test.ts index 6005296d..620cb939 100644 --- a/packages/node/src/index.test.ts +++ b/packages/node/src/index.test.ts @@ -29,6 +29,7 @@ import * as publicApi from './index'; /** Every VALUE the package exports (types are erased and cannot be asserted at runtime). */ const EXPECTED_EXPORTS = [ + 'BugseeStatus', 'PROFILING_OPTION_DEFINITIONS', 'ProfilingOption', 'RequestContextStoreToken', @@ -52,6 +53,8 @@ const EXPECTED_EXPORTS = [ 'guarded', 'launch', 'launchCore', + 'getStatus', + 'relaunch', 'neverThrow', 'openServerContext', 'openServerRequest', diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index d0bad0c6..b5065f7b 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -10,8 +10,7 @@ export type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/co // Re-exported so the backend adapters can contain their OWN pre-request work (header reads, route // extraction, the application-supplied `user` callback) without each taking a direct @bugsee/core // dependency. That work runs outside `runServerRequest` and the engine structurally cannot guard it. -export { guarded, neverThrow } from '@bugsee/core'; - +export { BugseeStatus, guarded, neverThrow } from '@bugsee/core'; // R-7/F4: the request-scoped active-span store for bare-`@bugsee/node` consumers wiring APM by // hand. One store per context source: the stash key is process-global (realm convergence), so two // live stores over the same source alias on it. The launch builds and owns one; hand-wirers must @@ -79,11 +78,13 @@ export type { InstanceIdentity } from './instance-layout'; export { type Bugsee, type BugseeLaunchOptions, + getStatus, type LaunchInternals, type LaunchResult, launch, launchCore, type NodeRuntime, + relaunch, } from './launch'; export { PROFILING_OPTION_DEFINITIONS, ProfilingOption } from './options'; export { diff --git a/packages/node/src/instance-layout.ts b/packages/node/src/instance-layout.ts index e3a8b8e7..7c94d2c5 100644 --- a/packages/node/src/instance-layout.ts +++ b/packages/node/src/instance-layout.ts @@ -14,6 +14,7 @@ import { ensureDir, writeFileSecure } from '@bugsee/node-utils'; const LIVE_FILE = '.live'; const OWNER_FILE = 'owner.json'; +const STOPPED_FILE = '.stopped'; const NONCE_LEN = 8; /** Identity + the per-instance subtree paths under a shared `dataDir`. */ @@ -29,6 +30,8 @@ export interface InstanceLayout { /** The liveness heartbeat file — its mtime is touched from the watchdog thread. */ readonly liveFile: string; readonly ownerFile: string; + /** Written by a clean `stop()`: tells a later launch this subtree is recoverable although its pid lives. */ + readonly stoppedFile: string; } /** The record persisted to `owner.json`, read by a peer to attribute + liveness-check a subtree. */ @@ -71,6 +74,7 @@ export function createInstanceLayout( incidentsDir: join(root, 'incidents'), liveFile: join(root, LIVE_FILE), ownerFile: join(root, OWNER_FILE), + stoppedFile: join(root, STOPPED_FILE), }; } diff --git a/packages/node/src/launch-lifecycle.test.ts b/packages/node/src/launch-lifecycle.test.ts new file mode 100644 index 00000000..161c6d26 --- /dev/null +++ b/packages/node/src/launch-lifecycle.test.ts @@ -0,0 +1,357 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + BugseeStatus, + createCaptureExporter, + createMemoryCaptureStore, + getCarrier, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { BugseeOption } from '@bugsee/protocol'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SystemProbe } from './environment'; +import { + type Bugsee, + type BugseeLaunchOptions, + getStatus, + launch, + type NodeRuntime, + relaunch, +} from './launch'; + +// Lifecycle (launch -> stop -> launch) behaviour of the Node composition root: status, relaunch, the +// introspectable launch options, and that repeated cycles leave nothing behind. + +const probe: SystemProbe = { + platformType: () => 'node', + runtimeVersion: () => '20.1.2', + osType: () => 'Linux', + osPlatform: () => 'darwin', + osRelease: () => '6.0', + osArch: () => 'arm64', + machine: () => 'x86_64', + cpuCount: () => 8, + totalMemory: () => 16_000, + freeMemory: () => 4_000, + utcOffsetMinutes: () => 0, + locale: () => 'en-US', +}; + +// A process fake that counts live listeners per event. +function fakeProcess() { + const listeners = new Map void>>(); + const proc = { + on(event: string, listener: (...args: unknown[]) => void) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]); + return proc; + }, + off(event: string, listener: (...args: unknown[]) => void) { + listeners.set( + event, + (listeners.get(event) ?? []).filter((l) => l !== listener), + ); + return proc; + }, + listeners: (event: string) => [...(listeners.get(event) ?? [])], + stderr: { write: () => true }, + exit: vi.fn(), + kill: vi.fn(), + pid: process.pid, + } as unknown as NodeRuntime; + return { + proc, + total: () => [...listeners.values()].reduce((n, l) => n + l.length, 0), + }; +} + +const okTransport = (): HttpTransport => + vi.fn( + async (_url: string, _options: HttpRequestOptions = {}): Promise => ({ + status: 200, + headers: {}, + body: new TextEncoder().encode(JSON.stringify({ access_token: 'a' })), + }), + ); + +let nonce = 0; +const dirs: string[] = []; +const opts = (over: Partial = {}): BugseeLaunchOptions => ({ + process: fakeProcess().proc, + transport: okTransport(), + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + detectHangs: false, + ...over, +}); + +const live: Bugsee[] = []; +afterEach(async () => { + await Promise.all(live.splice(0).map((c) => c.stop())); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); +const track = (token: string, options: BugseeLaunchOptions) => { + const client = launch(token, options); + live.push(client); + return client; +}; + +describe('status', () => { + it('is Stopped before any launch, Launched after, Stopping while draining, Stopped after', async () => { + expect(getStatus()).toBe(BugseeStatus.Stopped); + const client = track('tok', opts()); + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(client.getStatus()).toBe(BugseeStatus.Launched); + + const stopping = client.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); + expect(client.getStatus()).toBe(BugseeStatus.Stopping); + await stopping; + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(client.getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('a repeat launch while launched returns the singleton and keeps Launched', () => { + const first = track('tok', opts()); + const onError = vi.fn(); + expect(launch('tok', opts({ onError }))).toBe(first); + expect(onError).toHaveBeenCalledTimes(1); + expect(getStatus()).toBe(BugseeStatus.Launched); + }); + + it('a repeat or late stop() neither tears down twice nor evicts a relaunched client', async () => { + const first = track('tok', opts()); + await first.stop(); + const second = track('tok', opts()); + await first.stop(); // stale, repeated + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(launch('tok', opts({ onError: () => {} }))).toBe(second); // still the singleton + }); + + it('a repeat stop() tears the instrumentation down exactly once', async () => { + const uninstall = vi.fn(); + const client = track( + 'tok', + opts({ + serverInstrumentations: [{ install: () => {}, uninstall }], + instrumentIncomingRequests: true, + }), + ); + await client.stop(); + const after = uninstall.mock.calls.length; + await client.stop(); + expect(uninstall).toHaveBeenCalledTimes(after); + }); + + it('stopping a client that no longer owns the slot does not evict the newer one', async () => { + const stale = track('tok', opts()); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; + const newer = track('tok', opts()); + await stale.stop(); + expect(getCarrier().client).toBe(newer); + expect(getStatus()).toBe(BugseeStatus.Launched); + }); + + it('stopping a client that no longer owns the slot leaves the newer client and its status alone', async () => { + const stale = track('tok', opts()); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; // the slot is reset under it + const hang: HttpTransport = () => new Promise(() => {}); + const newer = track('tok', opts({ transport: hang })); + void newer.logException(new Error('in flight')); // keeps newer's stop() draining + void newer.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); + await stale.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopping); // the stale stop settled without touching it + }); + + it('a relaunch while the previous stop is still draining keeps its own status', async () => { + const first = track('tok', opts()); + const stopping = first.stop(); + const second = track('tok', opts()); + await stopping; + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(second.getStatus()).toBe(BugseeStatus.Launched); + }); + + it('stop() on a client that is no longer launched (killed) reports Stopped and frees the slot', async () => { + const client = track('tok', opts()); + vi.spyOn(client, 'isLaunched').mockReturnValue(false); + expect(await client.stop()).toBe(true); + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(getCarrier().client).toBeUndefined(); + }); +}); + +describe('launch -> stop -> launch cycles leave nothing behind', () => { + it('3 cycles: process listeners, console, the http server patch and the data dir all return to baseline', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bugsee-cycle-')); + dirs.push(dir); + const originalLog = console.log; + const originalEmit = http.Server.prototype.emit; + const proc = fakeProcess(); + const options = (): BugseeLaunchOptions => + opts({ + process: proc.proc, + dataDir: dir, + instanceIdentity: { pid: process.pid, threadId: 0, nonce: () => `n${nonce++}` }, + instrumentIncomingRequests: true, + }); + + let running = -1; + for (let cycle = 0; cycle < 3; cycle++) { + const client = launch('tok', options()); + expect(client.getStatus()).toBe(BugseeStatus.Launched); + expect(console.log).not.toBe(originalLog); // patched once… + expect(http.Server.prototype.emit).not.toBe(originalEmit); + expect(proc.total()).toBeGreaterThan(0); + if (running === -1) running = proc.total(); + expect(proc.total()).toBe(running); // no accumulation across cycles + + await client.stop(); + expect(getStatus()).toBe(BugseeStatus.Stopped); + expect(proc.total()).toBe(0); + expect(console.log).toBe(originalLog); // …and fully restored + expect(http.Server.prototype.emit).toBe(originalEmit); + expect(getCarrier().client).toBeUndefined(); + } + // Each cycle wrote its own subtree; none is left half-owned by a still-live writer (stop() closed it). + // Every stopped cycle's subtree was recovered and removed by the next launch; only the last remains. + await vi.waitFor(() => expect(readdirSync(dir)).toHaveLength(1)); + }); + + it('a log line is captured exactly once per cycle (no stacked interceptor)', async () => { + for (let cycle = 0; cycle < 3; cycle++) { + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + const client = track('tok', opts({ captureStore: store })); + console.log(`marker-${cycle}`); + const logs = (await createCaptureExporter(store).drain()).get('log') ?? []; + const hits = logs.filter((e) => JSON.stringify(e.data).includes(`marker-${cycle}`)); + expect(hits).toHaveLength(1); + await client.stop(); + } + }); +}); + +describe('a clean stop marks its on-disk subtree recoverable, an unclean one does not', () => { + const FIXED = { pid: process.pid, threadId: 0, nonce: () => 'fixed' }; + const dirFor = () => { + const dir = mkdtempSync(join(tmpdir(), 'bugsee-release-')); + dirs.push(dir); + return dir; + }; + const subtree = (dir: string) => join(dir, `${process.pid}-0-fixed`); + const stopped = (dir: string) => existsSync(join(subtree(dir), '.stopped')); + + it('marks the subtree after a fully drained stop', async () => { + const dir = dirFor(); + const client = launch('tok', opts({ dataDir: dir, instanceIdentity: FIXED })); + expect(stopped(dir)).toBe(false); // a running instance is never marked + expect(await client.stop()).toBe(true); + expect(stopped(dir)).toBe(true); + }); + + it('does NOT mark the subtree when the drain timed out (the upload may still be running)', async () => { + const dir = dirFor(); + const transport: HttpTransport = () => new Promise(() => {}); + const client = launch('tok', opts({ dataDir: dir, instanceIdentity: FIXED, transport })); + void client.logException(new Error('in flight')); + expect(await client.stop(20)).toBe(false); + expect(stopped(dir)).toBe(false); + }); + + it('routes a failure writing the marker to onError and still resolves the stop', async () => { + const dir = dirFor(); + const onError = vi.fn(); + const client = launch('tok', opts({ dataDir: dir, instanceIdentity: FIXED, onError })); + // Replace the marker path by a directory so the write fails. + mkdirSync(join(subtree(dir), '.stopped')); + expect(await client.stop()).toBe(true); + expect(onError).toHaveBeenCalled(); + expect(getStatus()).toBe(BugseeStatus.Stopped); + }); + + it('the NEXT launch in the same process recovers the stopped instance and removes its subtree', async () => { + const dir = dirFor(); + let n = 0; + const identity = { pid: process.pid, threadId: 0, nonce: () => `c${n++}` }; + const first = launch('tok', opts({ dataDir: dir, instanceIdentity: identity })); + await first.stop(); + expect(readdirSync(dir)).toHaveLength(1); + track('tok', opts({ dataDir: dir, instanceIdentity: identity })); + await vi.waitFor(() => expect(readdirSync(dir)).toEqual([`${process.pid}-0-c1`])); + }); +}); + +describe('launch options introspection', () => { + it('getLaunchOptions is host over defaults by canonical key; getHostLaunchOptions only what the host set', () => { + const client = track('tok', opts({ captureLogs: false, maxRecordingTime: 30 })); + const effective = client.getLaunchOptions(); + expect(effective[BugseeOption.CaptureLogs]).toBe(false); + expect(effective[BugseeOption.Duration]).toBe(30); + expect(effective[BugseeOption.CaptureSystemEvents]).toBe(true); // default + expect(effective[BugseeOption.MaxDataSize]).toBe(50); // node default + expect(client.getHostLaunchOptions()).toMatchObject({ + [BugseeOption.CaptureLogs]: false, + [BugseeOption.Duration]: 30, + }); + expect(client.getHostLaunchOptions()).not.toHaveProperty([BugseeOption.CaptureSystemEvents]); + }); + + it('never exposes secrets: no token, no transport/headers, only registered canonical keys', () => { + const client = track('SECRET-APP-TOKEN', opts({ endpoint: 'https://x.test' })); + for (const view of [client.getLaunchOptions(), client.getHostLaunchOptions()]) { + expect(JSON.stringify(view)).not.toContain('SECRET-APP-TOKEN'); + for (const key of Object.keys(view)) expect(key).toMatch(/^com\.bugsee\.option\./); + } + }); + + it('the host options are replaced on each launch, not merged', async () => { + const first = launch('tok', opts({ captureLogs: false })); + expect(first.getHostLaunchOptions()).toHaveProperty([BugseeOption.CaptureLogs], false); + await first.stop(); + const second = track('tok', opts()); + expect(second.getHostLaunchOptions()).not.toHaveProperty([BugseeOption.CaptureLogs]); + expect(second.getLaunchOptions()[BugseeOption.CaptureLogs]).toBe(true); + }); +}); + +describe('relaunch', () => { + it('rejects before any launch', async () => { + await expect(relaunch()).rejects.toThrow(/before launch/); + }); + + it('stops the current client, then launches a FRESH one with the same token and options', async () => { + const first = launch('tok-1', opts({ captureLogs: false })); + const second = await relaunch(); + live.push(second); + expect(second).not.toBe(first); + expect(first.getStatus()).toBe(BugseeStatus.Stopped); + expect(second.getStatus()).toBe(BugseeStatus.Launched); + expect(getStatus()).toBe(BugseeStatus.Launched); + expect(second.getHostLaunchOptions()).toHaveProperty([BugseeOption.CaptureLogs], false); + }); + + it('uses NEW options when given (they replace the previous host options)', async () => { + launch('tok', opts({ captureLogs: false })); + const second = await relaunch(opts({ maxRecordingTime: 15 })); + live.push(second); + expect(second.getHostLaunchOptions()).not.toHaveProperty([BugseeOption.CaptureLogs]); + expect(second.getLaunchOptions()[BugseeOption.Duration]).toBe(15); + }); + + it('3 relaunches in a row leave exactly one live client and no accumulated process listeners', async () => { + const proc = fakeProcess(); + launch('tok', opts({ process: proc.proc })); + const baseline = proc.total(); + for (let i = 0; i < 3; i++) { + live.push(await relaunch()); + expect(proc.total()).toBe(baseline); + } + }); +}); diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 38bb4e4f..151a1724 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -13,6 +13,7 @@ import { BUGSEE_SDK_VERSION, type BugseeApi, type BugseeClient, + BugseeStatus, type BundleAssemblyContext, type BundleStore, BundleStoreToken, @@ -20,6 +21,7 @@ import { ChunkStorageToken, type Clock, COMMON_OPTION_DEFINITIONS, + clearCarrierClient, composeFrameEnrichers, createBugseeApi, createBundleUploader, @@ -32,7 +34,9 @@ import { createUploadPipeline, defineService, getCarrierClient, + getCarrierStatus, getOrCreateInterceptor, + getProcessStatus, getServiceManifests, type HttpRequestOptions, type HttpResponse, @@ -41,11 +45,14 @@ import { type ReconcileOwnQueue, ReportMarkerStoreToken, type ReportSnapshotSource, + relaunchWith, resolveLaunchOptions, runLaunchRecovery, type Scheduler, SchedulerToken, setCarrierClient, + setCarrierLastLaunch, + setCarrierStatus, setStackAppRoot, TransportToken, } from '@bugsee/core'; @@ -58,6 +65,7 @@ import { createWorkerThreadRingWorker, httpRequest, nodeSha256Fallback, + writeFileSecure, } from '@bugsee/node-utils'; import type { ActiveSpanStore } from '@bugsee/performance'; import { BugseeOption, DEFAULT_FILENAMES, type EnvironmentEnvelope } from '@bugsee/protocol'; @@ -452,7 +460,18 @@ const safeCwd = (): string | undefined => { } }; +// The public composition root's lifecycle shell: it publishes Launched once a client was built (the Node +// launch is synchronous, so Launching is never observable here) and records the arguments for relaunch(). export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): LaunchResult { + const result = buildLaunch(appToken, options); + if (result.internals !== undefined) { + setCarrierLastLaunch(appToken, options, options.carrier); + setCarrierStatus(BugseeStatus.Launched, options.carrier); + } + return result; +} + +function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): LaunchResult { const proc = options.process ?? (process as unknown as NodeRuntime); // Tell core's frame scrubber where the application starts, so stack frames beneath it ship RELATIVE // (`./src/checkout.js`) instead of carrying the OS username, the home-directory layout and often an @@ -704,6 +723,7 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): ? { onReportSite: (error: unknown) => localVariables.captureReportSite(error) } : {}), launchOptions: resolved.options, + launchOptionsView: { effective: resolved.canonical, host: resolved.host }, services, // the internal container launch populated (transport + later seams) uploadPipeline, appToken, @@ -1065,9 +1085,44 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): // detection providers but knows nothing about this listener — and (b) clears the process Carrier slot // so a later launch() starts a fresh client (the singleton is released on stop). const stopCore = client.stop; + let tornDown = false; + // A CLEAN stop (everything drained) marks this instance's subtree recoverable. The owning pid stays + // alive across a launch -> stop -> launch cycle, so no sibling would ever judge the subtree dead and every + // cycle stranded up to `maxDataSize` of capture files; the marker lets the NEXT launch recover whatever + // is undelivered (retained bundles, pending incident markers) and remove the rest. A timed-out drain is + // NOT marked: its upload may still be running in this process, and recovering it now would duplicate it. + const markStopped = (drained: boolean): void => { + if (!drained || instanceLayout === undefined) { + return; + } + try { + writeFileSecure(instanceLayout.stoppedFile, ''); + } catch (error) { + options.onError?.(error); + } + }; const publicClient: Bugsee = { ...client, stop(timeout?: number): Promise { + // Settled: Stopped — unless a relaunch has already taken the process state over. + const settle = (): void => { + if (owned && getCarrierStatus(carrier) === BugseeStatus.Stopping) { + setCarrierStatus(BugseeStatus.Stopped, carrier); + } + }; + // A repeat stop() (or one after the collector killed the SDK) has nothing left to tear down; running + // the teardown again would double-dispose the writer and could evict a relaunched client's slot. + if (tornDown) { + return stopCore(timeout); + } + tornDown = true; + const owned = getCarrierClient(carrier) === publicClient; + if (owned) { + setCarrierStatus( + client.isLaunched() ? BugseeStatus.Stopping : BugseeStatus.Stopped, + carrier, + ); + } if (detectCrash) { proc.off('uncaughtException', onUncaughtException); // …and the rejection policy listener. Leaving it behind meant a STOPPED SDK still suppressed @@ -1092,8 +1147,13 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): for (const installable of serverInstallables) { installable.uninstall(); // restore http.Server.prototype / Bun.serve / Deno.serve } - setCarrierClient(undefined, carrier); - return stopCore(timeout); + clearCarrierClient(publicClient, carrier); + return stopCore(timeout) + .then((drained) => { + markStopped(drained); + return drained; + }) + .finally(settle); }, }; setCarrierClient(publicClient, carrier); @@ -1120,6 +1180,18 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): return { client: publicClient, internals }; } +/** The process lifecycle state (Stopped / Launching / Launched / Stopping), also while no client exists. + * `carrier` is the host the launch used (default `globalThis`). */ +export function getStatus(carrier?: object): BugseeStatus { + return getProcessStatus(carrier); +} + +/** Stop the running client (if any) and launch again with the same token; `options` replace the previous + * launch options when given. Uses the process carrier of `options.carrier` (default `globalThis`). */ +export function relaunch(options?: BugseeLaunchOptions): Promise { + return relaunchWith(launch, options, options?.carrier); +} + // The public composition root: the launched client. Equivalent to `launchCore(...).client` — `launchCore` // additionally surfaces the internal wiring (`LaunchInternals`) the `bugsee` umbrella uses to wire // on-by-default extensions; bare `@bugsee/node` callers use this and never see the internals. diff --git a/packages/node/src/liveness.test.ts b/packages/node/src/liveness.test.ts index 30dcdb98..3c958961 100644 --- a/packages/node/src/liveness.test.ts +++ b/packages/node/src/liveness.test.ts @@ -4,10 +4,12 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { DEFAULT_PATIENT_MS, + isCleanlyStopped, isSiblingDead, pidAlive, readLiveMtimeMs, readOwner, + STOPPED_FILE, } from './liveness'; const dirs: string[] = []; @@ -143,3 +145,13 @@ describe('isSiblingDead', () => { }); }); }); + +describe('isCleanlyStopped', () => { + it('is true only when the subtree carries the .stopped marker', () => { + const dir = mkDir(); + expect(isCleanlyStopped(dir)).toBe(false); + writeFileSync(join(dir, STOPPED_FILE), ''); + expect(isCleanlyStopped(dir)).toBe(true); + expect(STOPPED_FILE).toBe('.stopped'); + }); +}); diff --git a/packages/node/src/liveness.ts b/packages/node/src/liveness.ts index b2c297e9..6de13792 100644 --- a/packages/node/src/liveness.ts +++ b/packages/node/src/liveness.ts @@ -1,4 +1,5 @@ -import { statSync } from 'node:fs'; +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; import process from 'node:process'; import { readFileBytes } from '@bugsee/node-utils'; import { strFromU8 } from '@bugsee/util'; @@ -27,6 +28,19 @@ export function pidAlive( } } +/** Marker a CLEANLY stopped instance leaves in its subtree (see {@link isCleanlyStopped}). */ +export const STOPPED_FILE = '.stopped'; + +/** + * Whether the subtree's owner shut down cleanly (`stop()` ran and fully drained). Such a subtree is + * recoverable immediately even though its owning PROCESS is alive — the launch -> stop -> launch cycle of + * a long-lived process. Without this the alive pid kept every stopped subtree "live" until the process + * died, stranding up to `maxDataSize` of capture files per cycle. + */ +export function isCleanlyStopped(subtree: string): boolean { + return existsSync(join(subtree, STOPPED_FILE)); +} + /** Read + parse a subtree's `owner.json`, or undefined when it is missing or corrupt. */ export function readOwner(ownerFile: string): InstanceOwner | undefined { const bytes = readFileBytes(ownerFile); diff --git a/packages/node/src/recover-instances.test.ts b/packages/node/src/recover-instances.test.ts index 34a9be05..118690ce 100644 --- a/packages/node/src/recover-instances.test.ts +++ b/packages/node/src/recover-instances.test.ts @@ -24,6 +24,7 @@ import { import type { EnvironmentEnvelope } from '@bugsee/protocol'; import { strFromU8, unzipSync } from '@bugsee/util'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { STOPPED_FILE } from './liveness'; import { recoverInstances } from './recover-instances'; const dirs: string[] = []; @@ -203,6 +204,28 @@ describe('recoverInstances', () => { expect(existsSync(join(dir, '9-9-dead'))).toBe(false); // fully delivered → subtree removed }); + it('recovers a CLEANLY STOPPED sibling although its owning pid is alive, and leaves an un-stopped live one', async () => { + const dir = mkDir(); + // Both siblings belong to THIS (live) process; only the first shut down cleanly. + seedPendingBundle(dir, '1-0-stopped', 'b1', aBundle('left by a clean stop')); + seedPendingBundle(dir, '1-0-running', 'b2', aBundle('belongs to a live instance')); + writeOwner(dir, '1-0-stopped', process.pid); + writeOwner(dir, '1-0-running', process.pid); + writeFileSync(join(dir, '1-0-stopped', STOPPED_FILE), ''); + const pipe = fakePipeline(); + + await recoverInstances({ + dataDir: dir, + ownInstanceId: '1-0-live', + uploadPipeline: pipe, + context, + }); + + expect(pipe.bundles.map((b) => b.request.summary)).toEqual(['left by a clean stop']); + expect(existsSync(join(dir, '1-0-stopped'))).toBe(false); + expect(existsSync(join(dir, '1-0-running'))).toBe(true); + }); + it('rebuilds + delivers a dead sibling’s detected incident from its chunks, then removes the subtree', async () => { const dir = mkDir(); seedIncident(dir, '9-9-dead', 500, 'inc-1'); diff --git a/packages/node/src/recover-instances.ts b/packages/node/src/recover-instances.ts index a03a8906..e227a6d8 100644 --- a/packages/node/src/recover-instances.ts +++ b/packages/node/src/recover-instances.ts @@ -22,6 +22,7 @@ import { } from '@bugsee/node-utils'; import { DEFAULT_PATIENT_MS, + isCleanlyStopped, isSiblingDead, pidAlive, readLiveMtimeMs, @@ -221,6 +222,7 @@ export async function recoverInstances(options: RecoverInstancesOptions): Promis continue; } if ( + !isCleanlyStopped(sub) && !isSiblingDead( pidAlive(owner.pid, options.kill), readLiveMtimeMs(join(sub, '.live')),