From 5d4a2d494516eaaf35dcd96c52fbae31b37f48f9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 18:22:44 +0500 Subject: [PATCH 01/33] fix(replay): keep a replay base in the capture window; shed replay before logs under the byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BGSVIEWER-12: a web report's replay stayed on one half-loaded page because time-only eviction dropped rrweb's base (Meta + FullSnapshot) and left orphan incrementals. - anchor flag (out-of-band) set on the accepted FullSnapshot; carried over Electron IPC - ChunkCaptureStore: data/visual lanes, time eviction keeps the newest base at the window start, byte cap sheds replay first and evicts logs only when that makes room - disk-pressure probe counts one tick per store tick - browser: onVisualReset restarts the recorder with backoff; replay checkout cadence follows the window (clamp(window/2, 5s, 60s)) Export trim and clamp follow with the streaming export. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 12 + .../src/idb-chunk-backend.test.ts | 11 + .../browser-utils/src/idb-chunk-backend.ts | 1 + packages/browser/src/launch.test.ts | 7 +- packages/browser/src/launch.ts | 41 ++++ .../browser/src/launch.visual-reset.test.ts | 230 ++++++++++++++++++ packages/core/src/capture-aggregator.test.ts | 16 ++ packages/core/src/capture-aggregator.ts | 1 + packages/core/src/capture-data-entry.test.ts | 8 + packages/core/src/capture-data-entry.ts | 5 + .../core/src/capture-provider-base.test.ts | 22 ++ packages/core/src/capture-provider-base.ts | 4 +- packages/core/src/chunk-capture-store.test.ts | 213 ++++++++++++++++ packages/core/src/chunk-capture-store.ts | 175 +++++++++++-- packages/core/src/contracts.ts | 9 + packages/core/src/disk-pressure-backend.ts | 9 +- packages/core/src/disk-pressure-store.test.ts | 28 ++- .../core/src/memory-capture-store.test.ts | 13 + packages/core/src/memory-capture-store.ts | 2 + .../core/src/streaming-capture-store.test.ts | 8 + packages/core/src/streaming-capture-store.ts | 3 + packages/electron/src/main-receiver.test.ts | 22 ++ packages/electron/src/main-receiver.ts | 1 + packages/electron/src/protocol.test.ts | 17 ++ packages/electron/src/protocol.ts | 8 + packages/replay/src/recorder.test.ts | 34 +++ packages/replay/src/recorder.ts | 6 +- 27 files changed, 872 insertions(+), 34 deletions(-) create mode 100644 packages/browser/src/launch.visual-reset.test.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 4b084143..913cfd38 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1444,3 +1444,15 @@ One name, one writer per session (spec `platforms/javascript.md` "Host-owned tra Verified against current majors, all green with no adapter code changes: `@bugsee/koa` on Koa 3.2 (+ `@types/koa` 3), `@bugsee/nestjs` on Nest 12 (peer range widened to `^9 || ^10 || ^11 || ^12`; unit + real-Nest suites run on 12), `@bugsee/sveltekit` + `sveltekit-e2e` on SvelteKit 3 (fixture migrated: Kit 3 errors on `svelte.config.js`, options are passed to `sveltekit({ adapter })` in `vite.config.ts`; adapter-node 6). Dev deps now track the latest majors, so Nest 9-11, Koa 2 and Kit 2 are no longer exercised in CI. Not done (needs owner decision / separate work): Astro 7 (passes locally on Node 24, but its engine floor is Node >=22.23.4 and CI `node-version: 22` resolved to 22.23.3, so the astro-e2e build was refused; bump CI Node first), Preact 11 smoke, React Router 8 for `@bugsee/remix`, Kit 3 `handleError` status-influence semantics, Rollup 5 prerelease. **Follow-up (issue #11).** Astro 7 now dev-tested (`astro ^7`, `@astrojs/node ^11`; astro-e2e green); CI `node-version` is `>=22.23.4 <23` in both jobs (Astro 7's engine floor). Preact 11 smoke added, wired into the react `test` and `test:coverage` scripts (`packages/react/vitest.preact.config.ts` aliases `react` to `preact/compat`; `BugseeErrorBoundary` + HOC catch, report and render the fallback). React Router 8: `HandleErrorFunction` is unchanged, but RR's client `onError` passes `{ location, params, pattern, errorInfo }` (React's `ErrorInfo` is nested), so `bugseeOnError` now reads `info.errorInfo.componentStack` (bare `ErrorInfo` still accepted); before, the component stack was silently dropped. Kit 3 `handleError`: tested that an app handler's `{ message, status }` return is forwarded verbatim; reporting gates on the input status. Kit 3 no longer passes a top-level `status` (input is `{ kind, error, issues? }`, status inside `error`), so `effectiveStatus` reads it there for non-`unknown` kinds (kind first: the top-level `status` is a warning getter in Kit 3 dev); before, in production builds every 404 and `error(4xx)` would have been reported. The same filter now guards the client hook in `@bugsee/svelte`, and RR `bugseeOnError` skips thrown `Response`/`data()` control flow. Rollup 5.0.0-0 (prerelease, no notes) left out; keep watching. + +### BGSVIEWER-12: replay anchored to a base, visual-first shedding (2026-10-31) +**Defect:** a web error report's replay stayed on one half-loaded page while the URL trace showed several. Root cause: the capture store evicted replay purely by time (window 60 s, rrweb checkout cadence also 60 s), so the surviving window started with incremental events and no Meta/FullSnapshot; the viewer applied them to the wrong DOM. Reproduced on real rrweb 2.2.0 data. A second hole: the shared byte cap evicted in-window logs/events under heavy replay volume. +**Fix:** +- `anchor` flag, out-of-band on `CaptureDataEntry`/`StoredEntry` (never in the payload). The replay recorder flags rrweb's FullSnapshot (type 2) once the guard accepted it, so a dropped snapshot never leaves a base without a snapshot. It travels over the Electron renderer->main IPC as wire field `a: 1` (strictly `1`; anything else from the untrusted renderer is ignored). +- `ChunkCaptureStore` has two lanes (data / visual = replay+video) in one part-number space over the same backend, so snapshot and crash recovery are unchanged. Time eviction keeps the newest anchored part at or before the window start. The byte cap sheds visual first (orphans, then the oldest base segment, a lone segment whole plus a deferred `onVisualReset`) and evicts closed data only when that can make room (an oversized OPEN replay burst no longer wipes the in-window logs). +- `disk-pressure-backend` counts one tick per store tick (distinct `end`), not one per closed part, so the probe backoff / flap window do not run twice as fast while replay is recorded. +- Browser launch: `onVisualReset` -> recorder `checkout()` with exponential backoff (5 s doubling to 5 min, reset after 10 min quiet) so an oversized snapshot cannot loop; replay checkout cadence = `clamp(window/2, 5 s, 60 s)`; an explicit positive `replay.checkoutEveryNms` wins (undefined/0/NaN/non-numbers do not). +**Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. +**Known limit, Electron:** `onVisualReset` is wired only where the browser launch builds its own store. An Electron renderer streams to the main store (default 50 MB cap), which has no control message back to the renderer's recorder, so a base shed there is only restored by the next periodic checkout (bounded by the derived cadence). Renderers of several windows also share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows. A main->renderer `checkout` control command and per-window lanes are the follow-up. +**Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. +**Open (owner-approved order):** (a) streaming-export design doc (Android pattern; needs `Bundle.body`/`BundleStore`/assembler/replay-encoder contract changes); (b) streaming export with the export clamp (pre-window replay timestamps -> window start) and per-record leading-orphan trim; (c) then the default byte cap (20 MB in memory / 30 MB durable proposed, not yet confirmed). Until (b), the report start is still the earliest entry timestamp, so an anchor older than the window can stretch the timeline. diff --git a/packages/browser-utils/src/idb-chunk-backend.test.ts b/packages/browser-utils/src/idb-chunk-backend.test.ts index 982d01a2..5f1ac744 100644 --- a/packages/browser-utils/src/idb-chunk-backend.test.ts +++ b/packages/browser-utils/src/idb-chunk-backend.test.ts @@ -295,6 +295,17 @@ describe('createIdbChunkCaptureStore — chunk store over IndexedDB', () => { over: Partial[1]> = {}, ) => createIdbChunkCaptureStore(store, { clock: clockAt(10_000), generation: 42, ...over }); + it('forwards onVisualReset to the chunk store', async () => { + const calls: number[] = []; + const store = mk(keyed(), { maxDataSizeBytes: 100, onVisualReset: () => calls.push(1) }); + store.add({ type: 'replay', timestamp: 10_000, serialized: 'x'.repeat(120), anchor: true }); + store.tick(11_000); + store.add(rec('x', 'log', 1)); + await Promise.resolve(); + await Promise.resolve(); + expect(calls).toEqual([1]); + }); + it('defaults the clock (system) and generation (wallNow) when unspecified', async () => { const store = createIdbChunkCaptureStore(keyed()); // no clock, no generation → defaults store.add(rec('x', 'log', 1)); diff --git a/packages/browser-utils/src/idb-chunk-backend.ts b/packages/browser-utils/src/idb-chunk-backend.ts index 09938be0..d14fcce0 100644 --- a/packages/browser-utils/src/idb-chunk-backend.ts +++ b/packages/browser-utils/src/idb-chunk-backend.ts @@ -706,5 +706,6 @@ export function createIdbChunkCaptureStore( ? { onDiskWriteError: options.onDiskWriteError } : {}), ...(options?.onDiskReadError !== undefined ? { onDiskReadError: options.onDiskReadError } : {}), + ...(options?.onVisualReset !== undefined ? { onVisualReset: options.onVisualReset } : {}), }); } diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 669f9376..3a0073d7 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -3947,7 +3947,7 @@ describe('launch — session replay (lazy)', () => { 'function', ); expect(call[1]).toEqual({}); // the shared fileEncoders map (replay writes its encoder into it) - expect(call[2]).toEqual({ blackout: expect.any(Object) }); // no caller overrides → replay's own fail-closed defaults apply + expect(call[2]).toEqual({ blackout: expect.any(Object), checkoutEveryNms: 30_000 }); // default 60 s window → 30 s cadence; no caller overrides → replay's own fail-closed defaults apply }); it('records when the document arrives via window.document (the real-browser path)', async () => { @@ -4049,7 +4049,10 @@ describe('launch — session replay (lazy)', () => { it('enables replay with default options when replay is `true`', async () => { await launchTracked('tok', domReplayOptions({ replay: true })); await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1)); - expect(registerReplay.mock.calls[0]?.[2]).toEqual({ blackout: expect.any(Object) }); // no options object + expect(registerReplay.mock.calls[0]?.[2]).toEqual({ + blackout: expect.any(Object), + checkoutEveryNms: 30_000, + }); // no options object }); // Product decision: `replay` collects data and is ON by default, so an invalid scalar is OFF (fail diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index f9465a4f..ddb81e67 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -142,6 +142,12 @@ const DEFAULT_MAX_DATA_SIZE_MB = 10; /** Canonical identifier of the (browser-local) `replay` switch: no cross-SDK identity, so it lives here. */ /** How long persistence must hold before the replay recorder restarts for a fresh snapshot (see the disk-pressure hook). */ const REPLAY_CHECKOUT_QUIET_MS = 5000; +/** Longest replay checkout cadence (rrweb default) and the shortest we derive from a short window. */ +const REPLAY_CHECKOUT_MAX_MS = 60_000; +const REPLAY_CHECKOUT_MIN_MS = 5000; +/** Visual-reset restarts back off (x2 per consecutive reset) up to this, and start over after this much quiet. */ +const REPLAY_RESET_MAX_INTERVAL_MS = 300_000; +const REPLAY_RESET_QUIET_RESET_MS = 600_000; const REPLAY_OPTION_KEY = 'com.bugsee.option.replay.enabled'; /** Canonical identifier of the (browser-local) `persist` switch: no cross-SDK identity, so it lives here. */ const PERSIST_OPTION_KEY = 'com.bugsee.option.browser.persist'; @@ -853,9 +859,28 @@ async function buildLaunch( // self's namespace is fresh, so `cleanOtherGenerations` is left at its harmless default (nothing to clean). const maxRecordingTime = resolved.options.get(BugseeOption.Duration, 60); const maxDataSize = resolved.options.get(BugseeOption.MaxDataSize, DEFAULT_MAX_DATA_SIZE_MB); + // Set once the replay recorder exists. The store calls it when the byte cap forced every replay base out, so the + // recorder takes a fresh FullSnapshot; rate-limited so an oversized snapshot cannot loop snapshot -> drop -> snapshot. + let restartReplay: (() => void) | undefined; + let lastReplayRestart = Number.NEGATIVE_INFINITY; + let replayRestartInterval = REPLAY_CHECKOUT_QUIET_MS; const storeBounds = { maxRecordingTimeMs: maxRecordingTime * 1000, maxDataSizeBytes: maxDataSize * 1024 * 1024, + onVisualReset: (): void => { + const at = clock.monotonicNow(); + if (restartReplay === undefined || at - lastReplayRestart < replayRestartInterval) { + return; + } + // Resets that keep coming mean one snapshot does not fit the cap: back off (doubling, up to 5 min) instead of + // snapshotting the whole DOM every few seconds; a long quiet spell starts over from the base interval. + replayRestartInterval = + at - lastReplayRestart > REPLAY_RESET_QUIET_RESET_MS + ? REPLAY_CHECKOUT_QUIET_MS + : Math.min(replayRestartInterval * 2, REPLAY_RESET_MAX_INTERVAL_MS); + lastReplayRestart = at; + restartReplay(); + }, ...(options.clock !== undefined ? { clock: options.clock } : {}), }; const clock = options.clock ?? createSystemClock(); @@ -1245,6 +1270,14 @@ async function buildLaunch( ), ); } + // A base (FullSnapshot) must exist at the start of the window or the replay opens on orphans: snapshot at least + // twice per window (a non-finite window falls back to the longest cadence). + const derivedCheckoutMs = Number.isFinite(maxRecordingTime) + ? Math.max( + REPLAY_CHECKOUT_MIN_MS, + Math.min(REPLAY_CHECKOUT_MAX_MS, (maxRecordingTime * 1000) / 2), + ) + : REPLAY_CHECKOUT_MAX_MS; void import('@bugsee/replay') .then(async (m) => { // Canvas is an opt-in add-on: lazy-`import()` @bugsee/replay-canvas ONLY when replay.canvas is set, @@ -1258,7 +1291,14 @@ async function buildLaunch( ) : undefined; const recorder = m.registerReplay(client, fileEncoders, { + // A base (FullSnapshot) must exist at the start of the window or the replay opens on orphans: snapshot at + // least twice per window. An explicit positive replay.checkoutEveryNms wins; undefined / 0 / NaN do not + // (they would silently restore the 60 s default, or disable periodic snapshots). ...replayMasking, + checkoutEveryNms: + typeof replayMasking.checkoutEveryNms === 'number' && replayMasking.checkoutEveryNms > 0 + ? replayMasking.checkoutEveryNms + : derivedCheckoutMs, // The recorder follows the client's blackout: stopped for its whole span, restarted (a fresh full // snapshot) at the end — and never started at all if a blackout began before this chunk loaded. blackout: client.getService(BlackoutToken), @@ -1269,6 +1309,7 @@ async function buildLaunch( }); // `undefined` = a DOM-less host, where nothing records: only a real recorder makes `capture` claim `active`. if (recorder !== undefined) { + restartReplay = (): void => recorder.checkout(); captureState?.setVisualCapture(true); // Replay stops being persisted under pressure (the window stays in memory), so the durable stream has a // gap of incremental events. When persistence resumes, restart the recorder: the resumed tail then begins diff --git a/packages/browser/src/launch.visual-reset.test.ts b/packages/browser/src/launch.visual-reset.test.ts new file mode 100644 index 00000000..5a68aa1d --- /dev/null +++ b/packages/browser/src/launch.visual-reset.test.ts @@ -0,0 +1,230 @@ +import 'fake-indexeddb/auto'; +import type { LockManagerLike } from '@bugsee/browser-utils'; +import { CaptureStoreToken, type createMemoryCaptureStore } from '@bugsee/core'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { type DeviceIdLocalStorage, resetBrowserDeviceIdCache } from './device-id'; +import type { BrowserProbe } from './environment'; +import { type Bugsee, type BugseeLaunchOptions, launch } from './launch'; + +// BGSVIEWER-12 wiring: the replay checkout cadence follows the configured window (so a base always exists at the +// window start), and a byte-cap drop of the whole replay restarts the recorder (debounced) for a fresh base. +const { registerReplay } = vi.hoisted(() => ({ registerReplay: vi.fn() })); +vi.mock('@bugsee/replay', () => ({ registerReplay })); + +const MB = 1024 * 1024; +const DEVICE_ID = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'; +const probe: BrowserProbe = { + userAgent: () => 'Mozilla/5.0 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, +}; +const deviceIdLocalStorage = (): DeviceIdLocalStorage => { + const data = new Map([['bugsee.device_id', DEVICE_ID]]); + return { + getItem: (k) => data.get(k) ?? null, + setItem: (k, v) => void data.set(k, v), + removeItem: (k) => void data.delete(k), + }; +}; +const freeLocks: LockManagerLike = { + request(name, options, callback) { + if (options.ifAvailable) return Promise.resolve(callback({ name })); + void callback({ name }); + return new Promise(() => {}); + }, +}; + +const clients: Bugsee[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(clients.splice(0).map((c) => c.stop(0))); + resetBrowserDeviceIdCache(); + vi.clearAllMocks(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +let now = 1_000_000; +const clock = { wallNow: () => now, monotonicNow: () => now }; +const flush = async (): Promise => { + for (let i = 0; i < 4; i++) await Promise.resolve(); +}; + +function opts(over: Partial = {}): BugseeLaunchOptions { + return { + window: { addEventListener: () => {}, removeEventListener: () => {} } as unknown as Window, + document: { + visibilityState: 'visible', + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as Document, + transport: async () => ({ status: 200, headers: {}, body: new Uint8Array() }), + systemProbe: probe, + captureNetwork: false, + captureViewHierarchy: false, + captureInteractions: false, + persist: false, + clock, + indexedDB: new IDBFactory(), + locks: freeLocks, + deviceIdEnv: { localStorage: deviceIdLocalStorage(), randomUUID: () => DEVICE_ID }, + ...over, + }; +} +const launched = async (o: BugseeLaunchOptions): Promise => { + const client = await launch('tok', o); + clients.push(client); + await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1)); + return client; +}; +const cadenceFor = async (over: Partial): Promise => { + registerReplay.mockReturnValue({ checkout: vi.fn() }); + await launched(opts(over)); + return (registerReplay.mock.calls[0]?.[2] as { checkoutEveryNms?: number }).checkoutEveryNms; +}; + +describe('launch — replay checkout cadence follows the recording window', () => { + it('is half the window when that is under a minute', async () => { + expect(await cadenceFor({ maxRecordingTime: 20 } as Partial)).toBe(10_000); + }); + it('stays at one minute for a long window', async () => { + expect(await cadenceFor({ maxRecordingTime: 300 } as Partial)).toBe( + 60_000, + ); + }); + it('is never below five seconds', async () => { + expect(await cadenceFor({ maxRecordingTime: 4 } as Partial)).toBe(5000); + }); + it('ignores an explicit undefined or non-positive replay.checkoutEveryNms (the derived cadence stays)', async () => { + for (const bad of [undefined, 0, -1, Number.NaN, '45000']) { + registerReplay.mockClear(); + expect( + await cadenceFor({ + maxRecordingTime: 20, + replay: { checkoutEveryNms: bad }, + } as Partial), + ).toBe(10_000); + await Promise.all(clients.splice(0).map((c) => c.stop(0))); + } + }); + it('falls back to the longest cadence for a non-finite window', async () => { + expect(await cadenceFor({ maxRecordingTime: Number.NaN } as Partial)).toBe( + 60_000, + ); + }); + it('lets an explicit replay.checkoutEveryNms win', async () => { + expect( + await cadenceFor({ + maxRecordingTime: 20, + replay: { checkoutEveryNms: 45_000 }, + } as Partial), + ).toBe(45_000); + }); +}); + +describe('launch — a dropped replay restarts the recorder', () => { + // maxDataSize is in MB: an anchored 1.2 MB replay segment plus a log overflows a 1 MB cap with nothing else to shed. + const overflow = (store: ReturnType): void => { + store.add({ type: 'replay', timestamp: now, serialized: 'x'.repeat(1.2 * MB), anchor: true }); + store.tick(now + 1000); + store.add({ type: 'log', timestamp: now, serialized: '{}' }); + }; + + it('asks the recorder for a fresh base, and not again within the quiet period', async () => { + const checkout = vi.fn(); + registerReplay.mockReturnValue({ checkout }); + const client = await launched(opts({ maxDataSize: 1 } as Partial)); + await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1)); + await flush(); + const store = client.getService(CaptureStoreToken); + overflow(store); + await flush(); + expect(checkout).toHaveBeenCalledTimes(1); + now += 1000; + overflow(store); // another full drop inside the quiet period: suppressed (no snapshot storm) + await flush(); + expect(checkout).toHaveBeenCalledTimes(1); + now += 5000; + overflow(store); + await flush(); + expect(checkout).toHaveBeenCalledTimes(2); + }); + + it('backs off while resets keep coming (an oversized snapshot cannot loop every 5 s), and recovers after a quiet spell', async () => { + const checkout = vi.fn(); + registerReplay.mockReturnValue({ checkout }); + const client = await launched(opts({ maxDataSize: 1 } as Partial)); + await flush(); + const store = client.getService(CaptureStoreToken); + const fire = async (): Promise => { + overflow(store); + await flush(); + }; + await fire(); // 1st: immediately + now += 4999; + await fire(); // inside the 5 s base interval: suppressed + now += 1; + await fire(); // exactly 5 s: allowed (2nd) + expect(checkout).toHaveBeenCalledTimes(2); + now += 5000; + await fire(); // the interval has doubled to 10 s: suppressed + expect(checkout).toHaveBeenCalledTimes(2); + now += 5000; + await fire(); // 10 s since the 2nd: allowed (3rd) + expect(checkout).toHaveBeenCalledTimes(3); + now += 11 * 60_000; // a long quiet spell resets the backoff + await fire(); + now += 5000; + await fire(); // base interval again + expect(checkout).toHaveBeenCalledTimes(5); + }); + + it('a quiet spell of exactly ten minutes is not yet long enough to restart the backoff', async () => { + const checkout = vi.fn(); + registerReplay.mockReturnValue({ checkout }); + const client = await launched(opts({ maxDataSize: 1 } as Partial)); + await flush(); + const store = client.getService(CaptureStoreToken); + const fire = async (): Promise => { + overflow(store); + await flush(); + }; + await fire(); + now += 5000; + await fire(); // interval is now 10 s + now += 600_000; + await fire(); // gap == 10 min: the backoff continues (20 s) + now += 5000; + await fire(); // suppressed under a 20 s interval + expect(checkout).toHaveBeenCalledTimes(3); + }); + + it('does not spend the budget on a reset that arrives before the recorder exists', async () => { + const checkout = vi.fn(); + registerReplay.mockReturnValue({ checkout }); + const client = await launch('tok', opts({ maxDataSize: 1 } as Partial)); + clients.push(client); + const store = client.getService(CaptureStoreToken); + overflow(store); // the lazy replay import has not resolved yet: nothing to restart, nothing spent + await flush(); + expect(registerReplay).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1)); + overflow(store); // same instant: still the first restart the launch can honour + await flush(); + expect(checkout).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when no recorder exists (DOM-less host)', async () => { + registerReplay.mockReturnValue(undefined); + const client = await launched(opts({ maxDataSize: 1 } as Partial)); + await flush(); + expect(() => overflow(client.getService(CaptureStoreToken))).not.toThrow(); + await flush(); + }); +}); diff --git a/packages/core/src/capture-aggregator.test.ts b/packages/core/src/capture-aggregator.test.ts index 41e2ccf3..c1901f2f 100644 --- a/packages/core/src/capture-aggregator.test.ts +++ b/packages/core/src/capture-aggregator.test.ts @@ -29,6 +29,22 @@ describe('createCaptureAggregator', () => { expect(added).toEqual([{ type: 'network', timestamp: 1, serialized: e.serialize() }]); }); + it('carries an anchor entry to the store as an out-of-band flag, not in the payload', () => { + const { store, added } = fakeStore(); + const e = new CaptureDataEntryBase('replay', 1, { type: 4 }, true); + createCaptureAggregator(store).addEntry(e); + expect(added).toEqual([ + { type: 'replay', timestamp: 1, serialized: e.serialize(), anchor: true }, + ]); + expect(added[0]?.serialized).not.toContain('anchor'); + }); + + it('leaves the anchor flag off for an ordinary entry', () => { + const { store, added } = fakeStore(); + createCaptureAggregator(store).addEntry(new CaptureDataEntryBase('log', 1, {})); + expect(added[0]).not.toHaveProperty('anchor'); + }); + it("routes the entry's OWN serialize() output", () => { const { store, added } = fakeStore(); const custom: CaptureDataEntry = { diff --git a/packages/core/src/capture-aggregator.ts b/packages/core/src/capture-aggregator.ts index a356e78f..d664beb5 100644 --- a/packages/core/src/capture-aggregator.ts +++ b/packages/core/src/capture-aggregator.ts @@ -64,6 +64,7 @@ export function createCaptureAggregator( type: entry.type, timestamp: entry.timestamp, serialized: entry.serialize(), + ...(entry.anchor === true ? { anchor: true } : {}), ...(owner !== undefined ? { owner } : {}), }); } catch (error) { diff --git a/packages/core/src/capture-data-entry.test.ts b/packages/core/src/capture-data-entry.test.ts index cd828546..2dc4fe6a 100644 --- a/packages/core/src/capture-data-entry.test.ts +++ b/packages/core/src/capture-data-entry.test.ts @@ -15,6 +15,14 @@ describe('CaptureDataEntryBase', () => { expect(e.data).toBeUndefined(); }); + it('carries the anchor flag (default false) and never serializes it', () => { + expect(new CaptureDataEntryBase('replay', 1, {}).anchor).toBe(false); + const e = new CaptureDataEntryBase('replay', 1, { a: 1 }, true); + expect(e.anchor).toBe(true); + expect(e.serialize()).not.toContain('anchor'); + expect(JSON.parse(e.serialize())).toEqual({ timestamp: 1, data: { a: 1 } }); + }); + it('serializes timestamp and data to JSON', () => { const e = new CaptureDataEntryBase('log', 7, { message: 'hi' }); expect(JSON.parse(e.serialize())).toEqual({ timestamp: 7, data: { message: 'hi' } }); diff --git a/packages/core/src/capture-data-entry.ts b/packages/core/src/capture-data-entry.ts index f162ff05..bd40f3e6 100644 --- a/packages/core/src/capture-data-entry.ts +++ b/packages/core/src/capture-data-entry.ts @@ -11,6 +11,11 @@ export class CaptureDataEntryBase implements CaptureDataEntry { public readonly type: FileType, public timestamp = 0, public data: unknown = undefined, + /** + * Marks the first record of a self-contained base (rrweb's Meta before its FullSnapshot). It is carried + * to the store out-of-band and is NOT part of the serialized payload, so the wire is unchanged. + */ + public readonly anchor: boolean = false, ) {} serialize(): string { diff --git a/packages/core/src/capture-provider-base.test.ts b/packages/core/src/capture-provider-base.test.ts index b3e17c55..e72f5eb1 100644 --- a/packages/core/src/capture-provider-base.test.ts +++ b/packages/core/src/capture-provider-base.test.ts @@ -100,6 +100,28 @@ describe('CaptureProviderBase — lifecycle', () => { ]); }); + it('capture() flags an anchor entry, and leaves an ordinary one unflagged', () => { + class Anchoring extends CaptureProviderBase { + readonly name = 'anchoring'; + readonly controllingOption = 'captureNetwork'; + protected onStart(): void {} + emit(anchor?: boolean): void { + if (anchor === undefined) this.capture('replay', 1, {}); + else this.capture('replay', 1, {}, anchor); + } + } + const added: { anchor?: boolean }[] = []; + const p = new Anchoring(); + p.init({ + operations: createOperationDispatcher(), + captureAggregator: { addEntry: (e) => added.push(e), addEntries: () => {}, clear: () => {} }, + }); + p.emit(true); + p.emit(false); + p.emit(); + expect(added.map((e) => e.anchor === true)).toEqual([true, false, false]); + }); + it('throws when an entry is routed before init() (deps accessed via this.pipeline)', () => { const provider = new NetworkProvider(mkSource()); // never init()ed expect(() => provider.pushLog()).toThrow(/before init/); diff --git a/packages/core/src/capture-provider-base.ts b/packages/core/src/capture-provider-base.ts index 90b6176b..d56d7fb3 100644 --- a/packages/core/src/capture-provider-base.ts +++ b/packages/core/src/capture-provider-base.ts @@ -62,7 +62,7 @@ export abstract class CaptureProviderBase implements CaptureProvider { } /** Build the default JSON capture entry and route it in one call. */ - protected capture(type: FileType, timestamp: number, data: unknown): void { - this.addEntry(new CaptureDataEntryBase(type, timestamp, data)); + protected capture(type: FileType, timestamp: number, data: unknown, anchor = false): void { + this.addEntry(new CaptureDataEntryBase(type, timestamp, data, anchor)); } } diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 809705da..2db72200 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -48,6 +48,219 @@ describe('createChunkCaptureStore — backend protocol', () => { expect((await backend.listParts(0)).map((p) => p.number)).toEqual([1, 2]); // part 0 gone }); + describe('anchored time eviction', () => { + // One replay record per second labelled by that second; `anchorAt` lists the seconds that open a base. + // Returns the first second whose replay record is still held after `nowS` seconds with a 2 s window. + async function firstKeptSecond(anchorAt: number[], nowS: number): Promise { + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 2000, + }); + for (let s = 0; s < nowS; s++) { + store.add({ + type: 'replay', + timestamp: s * 1000, + serialized: `s${s}`, + ...(anchorAt.includes(s) ? { anchor: true } : {}), + }); + store.tick((s + 1) * 1000); + } + const replay = (await store.snapshot().drainAll()).get('replay') ?? []; + return replay.length === 0 ? undefined : Number(replay[0]?.serialized.slice(1)); + } + + it('keeps the only anchor part past the window instead of evicting it', async () => { + expect(await firstKeptSecond([0], 10)).toBe(0); // window start = 8000; second 0 is the base + }); + + it('evicts everything older than the newest anchor at or before the window start', async () => { + expect(await firstKeptSecond([0, 5], 10)).toBe(5); // anchor@5 (5000 <= 8000) supersedes anchor@0 + }); + + it('treats an anchor that starts exactly at the window start as the base', async () => { + // anchor@8 starts at 8000 == window start, so second 0 is no longer needed. Plain time eviction still keeps + // one part of slack (cutting = 7000), hence the first kept second is 6, not 8; a `<` boundary would keep 0. + expect(await firstKeptSecond([0, 8], 10)).toBe(6); + }); + + it('keeps an older anchor while the newer one is still inside the window', async () => { + expect(await firstKeptSecond([0, 9], 10)).toBe(0); // anchor@9 starts at 9000 > 8000: seconds 8.. need the base at 0 + }); + + it('evicts exactly as before when no anchor was ever recorded', async () => { + expect(await firstKeptSecond([], 10)).toBe(6); // cutting 7000 keeps one part of slack + }); + }); + + describe('byte cap sheds the visual lane (replay) before the data lane', () => { + // A labelled record of exactly `bytes` serialized bytes, so a test can tell WHICH records survived. + const rec = (type: FileType, label: string, bytes: number, anchor = false): StoredEntry => ({ + type, + timestamp: 0, + serialized: label.padEnd(bytes, '.'), + ...(anchor ? { anchor: true } : {}), + }); + const labels = async (store: ReturnType, type: FileType) => + ((await store.snapshot().drainAll()).get(type) ?? []).map((e) => + e.serialized.replace(/\.+$/, ''), + ); + const mk = (cap: number, onVisualReset?: () => void) => + createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 1_000_000, + maxDataSizeBytes: cap, + ...(onVisualReset !== undefined ? { onVisualReset } : {}), + }); + + it('drops the oldest base segment of the replay and keeps every log', async () => { + const store = mk(100); + store.add(rec('log', 'L0', 10)); + store.add(rec('replay', 'A0', 30, true)); + store.tick(1000); + store.add(rec('log', 'L1', 10)); + store.add(rec('replay', 'I1', 30)); + store.tick(2000); + store.add(rec('log', 'L2', 10)); + store.add(rec('replay', 'A2', 30, true)); + store.tick(3000); + store.add(rec('log', 'L3', 10)); + store.add(rec('replay', 'I3', 30)); // 160 bytes > 100: the segment A0+I1 (60) goes, never a log + expect(await labels(store, 'log')).toEqual(['L0', 'L1', 'L2', 'L3']); + expect(await labels(store, 'replay')).toEqual(['A2', 'I3']); + }); + + it('sheds replay records that precede the first base before anything else', async () => { + const store = mk(100); + store.add(rec('replay', 'O0', 40)); // an orphan: no base before it + store.tick(1000); + store.add(rec('replay', 'A1', 30, true)); + store.tick(2000); + store.add(rec('log', 'L2', 10)); + store.add(rec('replay', 'I2', 30)); // 110 > 100 → the orphan (40) is shed, the base stays + expect(await labels(store, 'replay')).toEqual(['A1', 'I2']); + expect(await labels(store, 'log')).toEqual(['L2']); + }); + + it('drops the whole replay (and asks for a fresh base) when one base segment alone exceeds the cap', async () => { + const reset = vi.fn(); + const store = mk(100, reset); + store.add(rec('replay', 'A0', 60, true)); + store.tick(1000); + store.add(rec('log', 'L1', 20)); + store.add(rec('replay', 'I1', 40)); // 120 > 100 and only one segment: nothing valid to keep + store.tick(2000); + store.add(rec('log', 'L2', 20)); + expect(await labels(store, 'log')).toEqual(['L1', 'L2']); + // The base is gone; the incremental that depended on it is still in the open part until a fresh base arrives + // (export trims leading records that precede a base), so the store makes no claim beyond "no A0". + expect(await labels(store, 'replay')).toEqual(['I1']); + expect(reset).toHaveBeenCalledTimes(1); + }); + + it('evicts the oldest data parts only once there is no replay left to shed', async () => { + const store = mk(30); + store.add(rec('log', 'L0', 20)); + store.tick(1000); + store.add(rec('log', 'L1', 20)); // logs alone: 40 > 30 → the oldest closed data part goes + expect(await labels(store, 'log')).toEqual(['L1']); + }); + + it('drops only the oldest segment when a newer base is already closed, and asks for no reset', async () => { + const reset = vi.fn(); + const store = mk(100, reset); + store.add(rec('replay', 'A0', 30, true)); + store.tick(1000); + store.add(rec('replay', 'A1', 30, true)); + store.tick(2000); + store.add(rec('log', 'L2', 50)); // 110 > 100 → only the segment before the next base (A0) goes + await Promise.resolve(); + await Promise.resolve(); + expect(await labels(store, 'replay')).toEqual(['A1']); + expect(reset).not.toHaveBeenCalled(); + }); + + it('never sheds the open visual part, even when it alone exceeds the cap', async () => { + const store = mk(50); + store.add(rec('replay', 'A0', 60, true)); // still open: not eligible + expect(await labels(store, 'replay')).toEqual(['A0']); + }); + + it('keeps every closed log when the excess is the OPEN replay part (evicting logs could not make room)', async () => { + const store = mk(100); + store.add(rec('log', 'L0', 20)); + store.tick(1000); + store.add(rec('log', 'L1', 20)); + store.tick(2000); + store.add(rec('replay', 'A2', 150, true)); // one second of replay bigger than the whole cap, still open + expect(await labels(store, 'log')).toEqual(['L0', 'L1']); + expect(await labels(store, 'replay')).toEqual(['A2']); + }); + + it('still evicts closed data when doing so does make room', async () => { + const store = mk(100); + store.add(rec('log', 'L0', 40)); + store.tick(1000); + store.add(rec('log', 'L1', 40)); + store.tick(2000); + store.add(rec('replay', 'A2', 60, true)); // 140 > 100; dropping L0 (40) fits + expect(await labels(store, 'log')).toEqual(['L1']); + }); + + it('swallows a throwing onVisualReset (a host callback must never break capture)', async () => { + const store = mk(100, () => { + throw new Error('host'); + }); + store.add(rec('replay', 'A0', 60, true)); + store.tick(1000); + store.add(rec('log', 'L1', 60)); // sheds the lone base: reset fires and throws + await Promise.resolve(); + await Promise.resolve(); + expect(await labels(store, 'log')).toEqual(['L1']); + }); + + it('closes the visual part with the tick end and its own byte size, opened at the data part start', async () => { + const backend = createMemoryChunkBackend(); + const store = createChunkCaptureStore(backend, { clock: clockAt(1000) }); + store.add(rec('replay', 'A0', 30, true)); + store.add(rec('log', 'L0', 10)); + store.tick(2000); + expect(await backend.listParts(0)).toEqual([ + { generation: 0, number: 0, start: 1000, end: 2000, byteSize: 10 }, + { generation: 0, number: 1, start: 1000, end: 2000, byteSize: 30 }, + { generation: 0, number: 2, start: 2000, end: undefined, byteSize: 0 }, + ]); + }); + + it('does not let an anchor flag on a data record extend that record past the window', async () => { + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 2000, + }); + store.add({ type: 'log', timestamp: 0, serialized: 'old', anchor: true }); + for (let s = 1; s <= 10; s++) { + store.tick(s * 1000); + } + expect((await store.snapshot().drainAll()).get('log') ?? []).toEqual([]); + }); + + it('forgets the open visual part on clear', async () => { + const store = mk(1_000_000); + store.add(rec('replay', 'A0', 10, true)); + store.clear(); + store.add(rec('replay', 'A1', 10, true)); + expect(await labels(store, 'replay')).toEqual(['A1']); + }); + + it('a store that never sees a visual record behaves exactly as before', async () => { + const backend = createMemoryChunkBackend(); + const store = createChunkCaptureStore(backend, { clock: clockAt(0), maxDataSizeBytes: 10 }); + store.add(rec('log', 'a', 8)); + store.tick(1000); + store.add(rec('log', 'b', 8)); + expect((await backend.listParts(0)).map((p) => p.number)).toEqual([1]); + }); + }); + it('removes a byte-cap-evicted part from the backend', async () => { const backend = createMemoryChunkBackend(); const store = createChunkCaptureStore(backend, { clock: clockAt(0), maxDataSizeBytes: 10 }); diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index faaf7f11..e413525d 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -33,6 +33,12 @@ export interface ChunkCaptureStoreOptions { onDiskReadError?: (error: unknown) => void; /** With `diskPressure`: how long a durable read may take before it counts as failed (default 30000 ms, a backstop). */ diskReadTimeoutMs?: number; + /** + * Called (once, deferred to a microtask so the recorder's synchronous restart cannot re-enter the store) when + * the byte cap forced the whole visual lane out and no new base is open: the host should restart its recorder so + * the next records start a fresh base (a replay's Meta + FullSnapshot). + */ + onVisualReset?: () => void; } // The in-memory index entry per live part (NO record data — that lives in the backend). @@ -42,8 +48,18 @@ interface IndexedPart { end: number | undefined; byteSize: number; count: number; + /** The part holds a record that opens a self-contained base (`StoredEntry.anchor`). */ + anchor: boolean; + /** `visual`: replay/video, shed first under the byte cap and kept anchored to a base. `data`: everything else. */ + readonly lane: Lane; } +type Lane = 'data' | 'visual'; + +// Types whose records depend on a base and are the bulk of the bytes. They live in their own lane of parts so the +// byte cap can shed them (whole base segments) before it touches logs, network or events. +const VISUAL_TYPES: ReadonlySet = new Set(['replay', 'video']); + export function createChunkCaptureStore( durableBackend: ChunkBackend, options?: ChunkCaptureStoreOptions, @@ -67,66 +83,170 @@ export function createChunkCaptureStore( const clock = options?.clock ?? createSystemClock(); const generation = backend.generation; - // Oldest-first; the last entry is the current (open) part. + // Every live part, oldest-first by part number; both lanes share one number space and one backend, so + // snapshot and crash recovery see plain parts. The open part of each lane is `dataPart` / `visualPart` (the visual + // part is opened lazily by the first visual record of a tick, so a store that never sees one is unchanged). let parts: IndexedPart[] = []; // Running byte total across all live parts; kept in sync with every add/evict so the byte cap and the // time window never double-count. let totalBytes = 0; let nextNumber = 0; + let visualPart: IndexedPart | undefined; - const openNewPart = (start: number): void => { + const openNewPart = (start: number, lane: Lane): IndexedPart => { const ref: PartRef = { generation, number: nextNumber }; nextNumber += 1; backend.openPart(ref, start); - parts.push({ ref, start, end: undefined, byteSize: 0, count: 0 }); + const part: IndexedPart = { + ref, + start, + end: undefined, + byteSize: 0, + count: 0, + anchor: false, + lane, + }; + parts.push(part); + return part; + }; + + let dataPart = openNewPart(clock.wallNow(), 'data'); + + const partFor = (lane: Lane): IndexedPart => { + if (lane === 'data') { + return dataPart; + } + visualPart ??= openNewPart(dataPart.start, 'visual'); + return visualPart; }; - const currentPart = (): IndexedPart => parts[parts.length - 1] as IndexedPart; + const evictPart = (part: IndexedPart): void => { + parts.splice(parts.indexOf(part), 1); + totalBytes -= part.byteSize; + backend.removePart(part.ref); + }; + + const closedParts = (lane: Lane): IndexedPart[] => + parts.filter((part) => part.lane === lane && part.end !== undefined); + + // Make room for a byte cap by dropping VISUAL records only, always leaving what remains valid: records before + // the first base are useless and go first; then the oldest base segment (everything before the next base); a lone + // segment cannot be shrunk and stays valid, so it goes whole and the host is asked for a fresh base. + // @returns whether anything was dropped. + const shedVisual = (): boolean => { + const closed = closedParts('visual'); + if (closed.length === 0) { + return false; + } + const firstBase = closed.findIndex((part) => part.anchor); + let upTo: number; + if (firstBase !== 0) { + upTo = firstBase === -1 ? closed.length : firstBase; + } else { + const nextBase = closed.findIndex((part, index) => index > 0 && part.anchor); + upTo = nextBase === -1 ? closed.length : nextBase; + } + for (const part of closed.slice(0, upTo)) { + evictPart(part); + } + if (upTo === closed.length && visualPart?.anchor !== true) { + // Nothing valid is left to show and the open part has no base: a fresh one is needed. + const reset = options?.onVisualReset; + if (reset !== undefined) { + void Promise.resolve().then(() => { + try { + reset(); + } catch { + /* a host callback must never break capture */ + } + }); + } + } + return true; + }; - // Evict whole oldest CLOSED parts until the byte total fits the cap. Never the open current part - // (parts.length > 1 guard): a lone oversized part is kept (soft bound). No-op when unbounded. + // The byte cap: shed the visual lane first, then evict the oldest CLOSED data part (only if that can make room). + // Never an open part: a lone oversized part is kept (soft bound). No-op when unbounded. const enforceByteCap = (): void => { if (maxDataSizeBytes === undefined) { return; } - while (totalBytes > maxDataSizeBytes && parts.length > 1) { - const dropped = parts.shift() as IndexedPart; - totalBytes -= dropped.byteSize; - backend.removePart(dropped.ref); + while (totalBytes > maxDataSizeBytes) { + if (shedVisual()) { + continue; + } + const closedData = closedParts('data'); + const oldest = closedData[0]; + if (oldest === undefined) { + break; + } + // An open replay part that cannot be shed is the excess: if evicting every closed data part would still not fit, + // the loss would buy nothing, so keep the in-window logs and let the cap be a soft bound. (Without a visual part + // the old rule stands: the newest, oversized record is kept and the history before it goes.) + if ( + visualPart !== undefined && + totalBytes - closedData.reduce((sum, part) => sum + part.byteSize, 0) > maxDataSizeBytes + ) { + break; + } + evictPart(oldest); } }; - openNewPart(clock.wallNow()); + // Evict one lane's closed parts that end outside the window. The visual lane keeps the newest anchored part at or + // before the window start (the BASE the window's first records depend on) and everything after it, even though it is + // older than the window; export trims what falls outside. No such part (no anchor recorded, or the only one is + // newer) evicts as plain time. + const evictByTime = (lane: Lane, nowMs: number): void => { + const cutting = nowMs - maxRecordingTimeMs - PART_DURATION_MS; + const windowStart = nowMs - maxRecordingTimeMs; + const laneParts = parts.filter((part) => part.lane === lane); + let evictable = laneParts.length; + if (lane === 'visual') { + for (let i = laneParts.length - 1; i >= 0; i--) { + const candidate = laneParts[i] as IndexedPart; + if (candidate.anchor && candidate.start <= windowStart) { + evictable = i; + break; + } + } + } + for (const part of laneParts.slice(0, evictable)) { + if (part.end === undefined || part.end >= cutting) { + break; + } + evictPart(part); + } + }; // Captured once, pre-bound, so the "does this backend support flush?" narrowing holds inside the closure. const backendFlush = backend.flush?.bind(backend); return { add(record: StoredEntry): void { - const part = currentPart(); + const part = partFor(VISUAL_TYPES.has(record.type) ? 'visual' : 'data'); const size = backend.appendEntry(part.ref, record); part.byteSize += size; part.count += 1; + if (record.anchor === true) { + part.anchor = true; + } totalBytes += size; enforceByteCap(); }, tick(nowMs: number): void { - const closing = currentPart(); - closing.end = nowMs; - backend.closePart(closing.ref, nowMs, closing.byteSize); - openNewPart(nowMs); - // Evict closed parts whose end is outside the window (keep one extra part, Android parity). - const cutting = nowMs - maxRecordingTimeMs - PART_DURATION_MS; - while ( - parts.length > 0 && - parts[0]?.end !== undefined && - (parts[0].end as number) < cutting - ) { - const dropped = parts.shift() as IndexedPart; - totalBytes -= dropped.byteSize; - backend.removePart(dropped.ref); + dataPart.end = nowMs; + backend.closePart(dataPart.ref, nowMs, dataPart.byteSize); + if (visualPart !== undefined) { + visualPart.end = nowMs; + backend.closePart(visualPart.ref, nowMs, visualPart.byteSize); + visualPart = undefined; } + dataPart = openNewPart(nowMs, 'data'); + // Evict closed parts whose end is outside the window (keep one extra part, Android parity). + evictByTime('data', nowMs); + evictByTime('visual', nowMs); }, snapshot(): CaptureSnapshot { @@ -140,7 +260,8 @@ export function createChunkCaptureStore( backend.removeGeneration(generation); parts = []; totalBytes = 0; - openNewPart(clock.wallNow()); + visualPart = undefined; + dataPart = openNewPart(clock.wallNow(), 'data'); }, // Forwarded only when the backend HAS one, so `'flush' in store` stays an honest test for "this store diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 350bdbba..65d7053a 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -52,6 +52,8 @@ export interface CaptureDataEntry { timestamp: number; /** The structured payload (what lands in the bundle file). */ data: unknown; + /** True for the first record of a self-contained base (see `StoredEntry.anchor`). Absent = false. */ + readonly anchor?: boolean; /** Serialize this entry to its stored string form. */ serialize(): string; /** Populate this entry from its stored string form (mutates `this`; Android instance deserialize). */ @@ -69,6 +71,13 @@ export interface StoredEntry { timestamp: number; /** The entry's serialized string form. */ serialized: string; + /** + * The record opens a self-contained base that later records of its type depend on (rrweb's Meta before its + * FullSnapshot). Out-of-band for the same reason as `timestamp`: the store must act on it without + * deserializing. Time eviction keeps the newest anchored part at or before the window start so a replay never + * begins with changes to a page it has no snapshot of. Absent = an ordinary record. + */ + anchor?: boolean; /** * Tenant/owner key for store partitioning, kept out-of-band for the same reason as `timestamp` — a store * must route on it without deserializing. Set from the active `RequestContext.owner`; `undefined` on the diff --git a/packages/core/src/disk-pressure-backend.ts b/packages/core/src/disk-pressure-backend.ts index 0253cf29..2c1d3a5f 100644 --- a/packages/core/src/disk-pressure-backend.ts +++ b/packages/core/src/disk-pressure-backend.ts @@ -224,8 +224,9 @@ export function withDiskPressure( parts.delete(key); }; - // Probe scheduling (see PROBE above). `ticks` counts closePart calls, i.e. the store's ~1 s tick. + // Probe scheduling (see PROBE above). `ticks` counts store ticks (closePart calls with a new `end`), i.e. ~1 s each. let ticks = 0; + let lastTickEnd: number | undefined; let nextProbeAt = 0; let probeEvery = 1; let releasedAt: number | undefined; @@ -512,6 +513,12 @@ export function withDiskPressure( options.onWriteError?.(error); // a seal refused for lack of room must not take the tick (or the probe) with it } } + // One store tick closes every open part (data and, when it holds replay, visual) with the SAME end: that is one + // tick, not two, or the probe backoff and the flap window would run twice as fast whenever replay is recorded. + if (end === lastTickEnd) { + return; + } + lastTickEnd = end; ticks += 1; if (ticks % 60 === 0) { largestRecent = Math.floor(largestRecent / 2); diff --git a/packages/core/src/disk-pressure-store.test.ts b/packages/core/src/disk-pressure-store.test.ts index 7d64cebe..dafee4b6 100644 --- a/packages/core/src/disk-pressure-store.test.ts +++ b/packages/core/src/disk-pressure-store.test.ts @@ -51,7 +51,9 @@ describe('disk pressure over the file capture store', () => { store.add(rec('log', 1)); store.add(rec('replay', 2)); store.add(rec('traces.system', 3)); - expect(storage.files(7, 0).sort()).toEqual(['log', 'meta', 'replay', 'traces.system']); + // Replay lives in the store's visual lane, so it is its own part on the medium (part 1) next to the data part (0). + expect(storage.files(7, 0).sort()).toEqual(['log', 'meta', 'traces.system']); + expect(storage.files(7, 1).sort()).toEqual(['meta', 'replay']); const all = await drain(store); expect(names(all.get('log'))).toEqual(['log@1']); expect(names(all.get('replay'))).toEqual(['replay@2']); @@ -497,6 +499,30 @@ describe('withDiskPressure — failures and surface', () => { expect(storage.chunks(1)).not.toContain(2_000_000_000); }); + it('counts ONE tick per store tick even when the replay lane closes a second part (the flap window is not halved)', () => { + const storage = createInMemoryChunkStorage(); + const pressure = createDiskPressure(); + const store = throwawayProbeStore(storage, { clock: clockAt(1000), diskPressure: pressure }); + const replay = (t: number): StoredEntry => ({ type: 'replay', timestamp: t, serialized: 'r' }); + let now = 1000; + const tick = (): void => { + store.add(replay(now)); // every second holds a replay record: two parts close per tick + now += 1000; + store.tick(now); + }; + pressure.noteWriteError({ code: 'ENOSPC' }); + tick(); + pressure.noteWriteRecovered(); // released + for (let i = 0; i < 6; i++) tick(); + pressure.noteWriteError({ code: 'ENOSPC' }); // pinned again 6 ticks after the release: a flap (<= 10), probe slows to every 2nd tick + const probes = vi.spyOn(storage, 'write'); + tick(); // due at +2, so no probe yet + expect(probes.mock.calls.some((c) => c[1] === 2_000_000_000)).toBe(false); + tick(); + tick(); + expect(probes.mock.calls.some((c) => c[1] === 2_000_000_000)).toBe(true); + }); + it('does not probe when not pinned (a size-reading none must not touch the full disk)', () => { const storage = createInMemoryChunkStorage(); const pressure = createDiskPressure(); diff --git a/packages/core/src/memory-capture-store.test.ts b/packages/core/src/memory-capture-store.test.ts index d6e8a57e..2315bb9c 100644 --- a/packages/core/src/memory-capture-store.test.ts +++ b/packages/core/src/memory-capture-store.test.ts @@ -213,3 +213,16 @@ describe('createMemoryCaptureStore — clear', () => { expect(got.map((r) => r.timestamp)).toEqual([4, 5]); }); }); + +describe('createMemoryCaptureStore — visual reset', () => { + it('forwards onVisualReset: a lone base segment over the cap is dropped and a fresh base requested', async () => { + const calls: number[] = []; + const store = mk({ maxDataSizeBytes: 100, onVisualReset: () => calls.push(1) }); + store.add({ type: 'replay', timestamp: 10_000, serialized: 'x'.repeat(120), anchor: true }); + store.tick(11_000); + store.add(rec('log', 11_000, '{}')); // over the cap with only one closed segment + await Promise.resolve(); + await Promise.resolve(); + expect(calls).toEqual([1]); + }); +}); diff --git a/packages/core/src/memory-capture-store.ts b/packages/core/src/memory-capture-store.ts index 8773baad..1e5f2e3f 100644 --- a/packages/core/src/memory-capture-store.ts +++ b/packages/core/src/memory-capture-store.ts @@ -18,6 +18,8 @@ export interface MemoryCaptureStoreOptions { maxDataSizeBytes?: number; /** Time source for the initial part / clear; injectable for tests. Default system clock. */ clock?: Clock; + /** Called when the byte cap forced the whole visual lane out and a fresh replay base is needed (see ChunkCaptureStoreOptions). */ + onVisualReset?: () => void; } export function createMemoryCaptureStore(options?: MemoryCaptureStoreOptions): CaptureStore { diff --git a/packages/core/src/streaming-capture-store.test.ts b/packages/core/src/streaming-capture-store.test.ts index aa74c5b1..c3520419 100644 --- a/packages/core/src/streaming-capture-store.test.ts +++ b/packages/core/src/streaming-capture-store.test.ts @@ -37,6 +37,14 @@ describe('createStreamingCaptureStore', () => { }); }); + it('carries the anchor flag of a base-opening record, and omits it otherwise', () => { + const { store, encoded } = harness(); + store.add({ ...entry('replay', 1, 'a'), anchor: true }); + store.add(entry('replay', 2, 'b')); + expect(encoded[0]?.anchor).toBe(true); + expect('anchor' in (encoded[1] ?? {})).toBe(false); + }); + it('assigns a monotonically increasing seq by default', () => { const { store, encoded } = harness(); store.add(entry('log', 1, 'a')); diff --git a/packages/core/src/streaming-capture-store.ts b/packages/core/src/streaming-capture-store.ts index 3f5ba7ec..ecd92428 100644 --- a/packages/core/src/streaming-capture-store.ts +++ b/packages/core/src/streaming-capture-store.ts @@ -21,6 +21,8 @@ export interface StreamingCaptureEntry { payload: string; /** Redaction provenance: did a local filter already run for this entry type? */ redacted: boolean; + /** The record opens a self-contained base (`StoredEntry.anchor`); present only when true. */ + anchor?: true; } export interface StreamingCaptureStoreOptions { @@ -85,6 +87,7 @@ export function createStreamingCaptureStore(opts: StreamingCaptureStoreOptions): timeOrigin, payload: record.serialized, redacted: redactedFor(record.type), + ...(record.anchor === true ? { anchor: true as const } : {}), }), ); }, diff --git a/packages/electron/src/main-receiver.test.ts b/packages/electron/src/main-receiver.test.ts index eaeec052..f1794978 100644 --- a/packages/electron/src/main-receiver.test.ts +++ b/packages/electron/src/main-receiver.test.ts @@ -54,6 +54,28 @@ describe('createElectronMainReceiver', () => { expect(s.added).toEqual([{ type: 'network', timestamp: 500, serialized: '{"u":"x"}' }]); }); + it('forwards the anchor flag into the main store (a renderer base must survive eviction there too)', () => { + const ipc = fakeIpcMain(); + const s = fakeStore(); + createElectronMainReceiver({ ipcMain: ipc.ipcMain, store: s.store }).start(); + const anchored = encodeStreamEntry({ + type: 'replay' as never, + seq: 1, + timestamp: 5, + mono: 0, + timeOrigin: 0, + redacted: false, + payload: '{}', + anchor: true, + }); + ipc.emit(BUGSEE_STREAM_CHANNEL, { sender: { id: 1 } }, anchored); + ipc.emit(BUGSEE_STREAM_CHANNEL, { sender: { id: 1 } }, wire('replay', 6, '{}')); + expect(s.added).toEqual([ + { type: 'replay', timestamp: 5, serialized: '{}', anchor: true }, + { type: 'replay', timestamp: 6, serialized: '{}' }, + ]); + }); + it('calls onEntry with the decoded entry + originating window id', () => { const ipc = fakeIpcMain(); const s = fakeStore(); diff --git a/packages/electron/src/main-receiver.ts b/packages/electron/src/main-receiver.ts index 00217467..9286c2b6 100644 --- a/packages/electron/src/main-receiver.ts +++ b/packages/electron/src/main-receiver.ts @@ -79,6 +79,7 @@ export function createElectronMainReceiver( type: decoded.type, timestamp: decoded.timestamp, serialized: decoded.payload, + ...(decoded.anchor === true ? { anchor: true } : {}), }); options.onEntry?.(decoded, windowId); } catch (error) { diff --git a/packages/electron/src/protocol.test.ts b/packages/electron/src/protocol.test.ts index 7ad24f82..4113827b 100644 --- a/packages/electron/src/protocol.test.ts +++ b/packages/electron/src/protocol.test.ts @@ -54,6 +54,23 @@ describe('decodeStreamEntry', () => { }); }); + it('round-trips the anchor flag, putting nothing extra on the wire for an ordinary entry', () => { + const anchored = encodeStreamEntry(streamEntry({ anchor: true })); + expect(decodeStreamEntry(anchored)?.anchor).toBe(true); + const plain = encodeStreamEntry(streamEntry()); + expect(JSON.parse(plain)).not.toHaveProperty('a'); + expect(decodeStreamEntry(plain)).not.toHaveProperty('anchor'); + }); + + it('honours only the exact wire flag `a: 1` from the (untrusted) renderer', () => { + for (const a of ['yes', true, 'true', null, {}, false, 2]) { + const raw = JSON.stringify({ k: 'entry', t: 'replay', s: 1, ts: 1, p: {}, a }); + expect(decodeStreamEntry(raw)).not.toHaveProperty('anchor'); + } + const raw = JSON.stringify({ k: 'entry', t: 'replay', s: 1, ts: 1, p: {}, a: 1 }); + expect(decodeStreamEntry(raw)?.anchor).toBe(true); + }); + it('returns undefined for invalid JSON', () => { expect(decodeStreamEntry('<>')).toBeUndefined(); }); diff --git a/packages/electron/src/protocol.ts b/packages/electron/src/protocol.ts index 15a18ca6..aef0b4fd 100644 --- a/packages/electron/src/protocol.ts +++ b/packages/electron/src/protocol.ts @@ -20,6 +20,8 @@ export interface DecodedStreamEntry { timeOrigin: number; redacted: boolean; payload: string; + /** Present only when the renderer flagged a base-opening record (a strict boolean `true` on the wire). */ + anchor?: true; } /** Encode a streaming capture entry to a wire string, splicing the already-serialized payload in verbatim. */ @@ -32,6 +34,8 @@ export function encodeStreamEntry(entry: StreamingCaptureEntry): string { mono: entry.mono, o: entry.timeOrigin, red: entry.redacted, + // Only a base-opening record carries the flag, so an ordinary entry's wire form is unchanged. + ...(entry.anchor === true ? { a: 1 } : {}), }); // Splice the payload as the raw JSON value of `p` (no re-parse/escape of the payload on the hot path). return `${head.slice(0, -1)},"p":${entry.payload}}`; @@ -45,6 +49,7 @@ interface WireEntry { mono?: number; o?: number; red?: boolean; + a?: unknown; p?: unknown; } @@ -129,6 +134,9 @@ export function decodeStreamEntry(raw: string): DecodedStreamEntry | undefined { mono, timeOrigin, redacted: message.red ?? false, + // The wire flag is `1`; anything else a renderer sends (a string, `true`, an object) is not a flag. The flag only + // steers which records the main store keeps, so a misbehaving renderer can at worst hold extra replay parts. + ...(message.a === 1 ? { anchor: true as const } : {}), // Re-serialize the payload for the aggregator's `StoredEntry.serialized` (main-side, off the hot path). payload, }; diff --git a/packages/replay/src/recorder.test.ts b/packages/replay/src/recorder.test.ts index 9e0b462f..f5c123a8 100644 --- a/packages/replay/src/recorder.test.ts +++ b/packages/replay/src/recorder.test.ts @@ -118,6 +118,40 @@ describe('createReplayCaptureProvider', () => { }); }); + it('marks only an ACCEPTED FullSnapshot as an anchor (the base the store keeps), never Meta or an incremental', () => { + const { record, emit } = fakeRecord(); + const { init, added } = fakeInit(); + const provider = createReplayCaptureProvider({ + record, + masking: resolveReplayMaskingOptions(), + }); + provider.init(init); + provider.start(options()); + emit(meta('https://a.com/')); + emit({ + type: 2, + data: { node: {}, initialOffset: { top: 0, left: 0 } }, + timestamp: 6, + } as never); + emit(evt(7)); + emit({ type: 5, data: { tag: 'x', payload: {} }, timestamp: 8 } as never); + expect(added.map((e) => e.anchor === true)).toEqual([false, true, false, false]); + }); + + it('a FullSnapshot the guard drops leaves its Meta unanchored (no base without a snapshot)', () => { + const { record, emit } = fakeRecord(); + const { init, added } = fakeInit(); + const provider = createReplayCaptureProvider({ + record, + masking: resolveReplayMaskingOptions(), + }); + provider.init(init); + provider.start(options()); + emit(meta('https://a.com/')); + emit({ type: 2, data: {}, timestamp: 6 } as never); // no node tree: the guard drops it as malformed + expect(added.some((e) => e.anchor === true)).toBe(false); + }); + it('passes every other event, and a Meta event without a string href, through as the same object', () => { const { record, emit } = fakeRecord(); const { init, added } = fakeInit(); diff --git a/packages/replay/src/recorder.ts b/packages/replay/src/recorder.ts index b45a56d9..7b9e4932 100644 --- a/packages/replay/src/recorder.ts +++ b/packages/replay/src/recorder.ts @@ -223,7 +223,11 @@ class ReplayCaptureProvider extends CaptureProviderBase implements ReplayRecorde // The guard's report goes to host code, which may have started a blackout (or stopped the recorder) // in the meantime: whatever the guard let through is dropped then too. if (safe !== undefined && session === this.#session && !this.#blackedOut) { - this.capture('replay', safe.timestamp, withSafeHref(safe)); + // rrweb's FullSnapshot (type 2) completes every self-contained base (Meta + FullSnapshot, emitted + // synchronously together at the start and at each checkout): flag it so the store keeps the newest base at + // the window start (StoredEntry.anchor). Flagging the FullSnapshot, not the Meta, means a snapshot the + // guard dropped never leaves a base that has no snapshot. + this.capture('replay', safe.timestamp, withSafeHref(safe), safe.type === 2); } } }, From 90a09cac25e1dc0b0c02426d615a0a75a374e914 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 19:34:16 +0500 Subject: [PATCH 02/33] docs(design): streaming report export (Android pattern, window-bounded manifest, replay clamp/trim) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved design for the streaming export slices S0-S6; Q1-Q5 accepted as proposed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 245 ++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/design/streaming-export.md diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md new file mode 100644 index 00000000..98a9d21f --- /dev/null +++ b/docs/design/streaming-export.md @@ -0,0 +1,245 @@ +# Streaming report export + +Status: **Approved by the owner (2026-10-31); decisions Q1–Q5 accepted as proposed (section 7).** Nothing here is implemented yet. Follows the BGSVIEWER-12 store work +(`docs/PROGRESS.md`, 2026-10-31) and is the vehicle for its two unfinished halves: the export clamp and trim. + +## 1. Why + +Today a report is assembled entirely in memory: + +``` +store.snapshot().drainAll() -> Map (all serialized records, every type) + -> reify each record into a CaptureDataEntry (second copy: parsed objects) + -> JSON.stringify per type / encodeReplay (JSON + gzipSync) (third copy: strings, then bytes) + -> zipSync (fourth copy: the archive) + -> Bundle.body: Uint8Array -> BundleStore.put / serializeBundle (fifth copy: header + body concatenated) + -> putBundle(url, Uint8Array) +``` + +(`core/src/client.ts:1026` `collectCapture`, `bundle-assembler.ts:196` `assembleBundle`, `bundle-writer.ts` `zipSync`, +`durable-upload-pipeline.ts:176` `serializeBundle`, `upload-pipeline.ts:179` `sha256(bundle.body)`.) + +With the byte cap at 10 MB that was tolerable. The owner wants a larger cap (20 MB in memory, 30 MB durable proposed), and +a report is built at the worst moment: a crash, an out-of-memory-adjacent error, a page being hidden. Peak heap at +export is a multiple of the window, not the window. **Export must not hold the window in memory.** The mobile SDKs +already work this way, which is the reference (section 3). + +Two correctness items ride on the same change, because both need a place where records flow past one at a time: + +1. **Time bounds.** `manifest.time.start` is the earliest entry timestamp (`bundle-assembler.ts:163-167`) and the viewer + anchors replay on `events[0].timestamp`. Since BGSVIEWER-12 the store deliberately keeps a replay base that is older + than the window, so the earliest entry can now be up to one checkout cadence (30 s by default) before the window. + The report's timeline would stretch by that much. The spec defines `time.start` as the *snapshot start* + (`specs/sdk/reporting/bundle/manifest.md`), i.e. the window start, not the earliest record. +2. **Leading orphans.** The store's anchor is per part, so a kept part can begin with incrementals of the previous base + (up to ~1 s), and after a lone-base drop the open part holds incrementals until a fresh base arrives. The Replayer + applies orphan incrementals to the wrong DOM (proved on rrweb 2.2.0 for BGSVIEWER-12). Export must trim records that + precede the first base, per record. + +Goal: **every record of the reporting window is exported, bounded memory, a correct timeline, no orphan replay.** + +## 2. Non-goals + +* Changing the bundle layout or any wire name. A streamed bundle must be byte-for-byte *readable* by the same worker and + viewer (member names, `request.json`, `manifest.json` shape, `replay.bin` = gzip of a JSON event array). + Member *order* inside the zip is not part of the contract (section 4.5). +* Streaming the upload of *incoming* data, the dialog flow, or the notify pipeline. +* A second export format. Android's temp-directory step has a JS analogue only where the platform has a disk. +* Changing the store's retention rules again. Retention keeps a superset; export decides what leaves. + +## 3. Reference: the Android export + +(`android/sdk/library/.../capture/CaptureExporter.java`, `CaptureExportProcessorDefault.java`, +`shared/compression/ZipArchive.java`.) + +* One `CaptureExporter` **per export**, closed in a `finally`; exports are serialized by an `ExportLane`, with + cancellation checks at stage boundaries ("before internal logs", "before postProcess"). +* The caller passes a **`timeRange` (`long[2]`)**, derived from the snapshot's part index. It is not computed from the + records. `timeRange[0] < timeRange[1]` is the "is there any data" test. +* **One pass over the snapshot's parts**; each entry goes to a per-type incremental JSON serializer + (`mJsonSerializers`, `beginArray` ... `endArray`) that writes straight to a file in an output directory. Custom + processors (video) mux incrementally and flush in `postProcess()`. +* **Then** `ZipArchive.zip()` streams each output file into a `ZipOutputStream` with an 8-ish KB buffer, and the upload + reads the zip file. Nothing but a buffer is ever in memory. + +The JS port keeps the shape (plan, single demultiplexing pass, incremental per-type encoders, a zip stage, upload from +the artifact) and replaces "directory of files" with whatever the platform can hold cheaply (section 4.4). + +## 4. Design + +### 4.1 Pipeline + +``` +snapshot.stream() oldest-to-newest across parts, one StoredEntry at a time + -> plan.accepts(record) window trim (non-replay) — section 4.3 + -> demux by FileType one incremental encoder per type, created on first record + JSON types : '[' , record , ',' ... records are already serialized; spliced verbatim (see 4.2) + replay : orphan trim -> timestamp clamp -> '[' event ',' ... -> gzip stream + -> fflate Zip (one ZipDeflate member per type) -> BundleSink +``` + +`ReportSnapshotSource`s (view tree, video) and the crash/attachment/request members are small and keep producing +in-memory members; they are added to the same zip. + +**fflate behaviour verified** (fflate 0.8.3, probe in the session scratchpad): a `Zip` accepts pushes to *several members +in interleaved order*. Output for a member that is not yet at the head is **compressed and buffered**, not raw: pushing +5 MB of a repetitive payload into the second member while the first was open buffered 4,982 bytes, all emitted when the +first member was finalized. So single-pass demux needs **no temp files and no custom zip writer**; peak memory is +the *compressed* size of the members waiting behind the head, roughly an order of magnitude below the window. (S0 measures +this on a real window, not a repetitive string.) + +### 4.2 Records are already serialized + +A `StoredEntry.serialized` is `JSON.stringify({timestamp, data})`. Today export parses every record (`reify`) and then +stringifies the `data` again. Streaming splices instead: for a plain-JSON type the member is +`[` + `data` text of record 1 + `,` + ... + `]`. Extracting `data` from the serialized envelope without a parse is +fragile, so S2 first changes the **store frame** (not the wire) to keep the payload separable, or parses once and +re-stringifies `data` per record (one record's worth of garbage at a time, which is already bounded memory). Decision +in section 7, Q2; the second option is the default because it needs no store change. + +Per-type shape wrappers (`performance` -> `{transactions:[...]}`, `input`/`video.aux` -> `{version, events:[...]}`, +`profile` -> the single object) become **prefix/suffix strings** around the incremental array, moved out of +`serializeFileData` into one table so the buffered and streaming paths cannot drift. + +### 4.3 Time plan + +The plan is computed **once, before the pass**, from the snapshot, not from the records: + +``` +plan.end = trigger moment (clock.wallNow(), as today) +plan.start = max(plan.end - window, store epoch) store epoch = first part start since launch/clear +``` + +* New optional `CaptureSnapshot.timeRange(): { start: number; end: number } | undefined`, computed by the store at + `snapshot()` time from its own window and part index (Android's `timeRange`). `undefined` (a backend that cannot say) + falls back to today's rule, earliest entry. +* `manifest.time = { start: plan.start, end: plan.end }`. This is the report start the viewer shows. +* **Replay clamp.** Replay events older than `plan.start` (the retained base and the incrementals up to the window + start) are exported with `timestamp = plan.start`, in order. Applying them at one instant reproduces the page state at + the window start, and `events[0].timestamp == manifest.time.start`, so the player timeline equals the report timeline. + Their relative order is preserved by position; rrweb orders by array position within equal timestamps. +* **Non-replay trim.** Records of every other type with `timestamp < plan.start` are dropped. The store keeps up to one + extra second (`PART_DURATION_MS` slack); the configured window is the contract. See Q3 for the alternative (keep and + move `start` down to the earliest kept record). +* The clamp and trim apply to **exported bytes only**. The store and the durable copy are not rewritten. + +### 4.4 Orphan trim (replay) + +A small state machine in the replay stream encoder: drop events until the first FullSnapshot (type 2) *preceded by its +Meta* (type 4), then pass everything. If a Meta is the last thing before the base, it is kept; a Meta with no following +FullSnapshot is dropped with the orphans. If **no base exists at all**, the member is omitted (not written empty) and +`onError` is told; the manifest does not list it. This is per record, so it also covers the in-part case the store +cannot (see "Known limits" in the BGSVIEWER-12 entry of `docs/PROGRESS.md`). + +The encoder lives in `@bugsee/replay` behind a core contract: + +```ts +interface FileStreamEncoder { // registered per FileType, like fileEncoders today + begin(plan: ExportPlan): StreamEncoderSession; +} +interface StreamEncoderSession { + write(entry: StoredEntry): Uint8Array[] | void; // zero or more output chunks + end(): Uint8Array[]; // trailer; [] and a flag if the member is empty + readonly empty: boolean; // true => omit the member and its manifest entry +} +``` + +`fileEncoders` (buffered, `(payloads) => Uint8Array`) stays for third-party/extension encoders and as the fallback for +types with no stream encoder. A type goes down the streaming path iff a `FileStreamEncoder` is registered or it is one +of the built-in JSON types. + +### 4.5 The artifact and the sink + +The zip output is a stream of chunks; where they go is a platform decision, exactly as Android chose files: + +```ts +interface BundleBody { // replaces `Uint8Array` in Bundle.body + readonly size: number; // Content-Length for the signed PUT + stream(): AsyncIterable; // RESTARTABLE: a retry re-reads from the start + bytes(): Promise; // convenience; materializes (tests, small bundles, hashing) + dispose(): void | Promise; // delete the temp file / drop the blob +} +interface BundleSink { open(fileName: string): BundleSinkWriter } +interface BundleSinkWriter { write(chunk: Uint8Array): void | Promise; finish(): Promise; abort(): void } +``` + +| Runtime | Sink | Body | +| --- | --- | --- | +| browser, web/service worker, Deno, Bun | `Blob` parts (the engine spills big Blobs to disk) | `Blob` (`stream()` = `blob.stream()`) | +| Node, Electron main | temp file under the data dir (`/export/.zip`), or the durable queue file directly | file (`fs.createReadStream`) | +| edge (Workers, Vercel) | in-memory chunks (no disk; the window is small there) | chunk array | + +The memory sink is also the default for tests and any platform that does not inject one, so behaviour degrades to +"today, minus the intermediate copies". + +* **Order of members.** `request.json`, `apptoken`, then data members as they complete, `manifest.json` **last**: its + `files` list is only known after the pass (empty types and the omitted replay are not listed), while `time` comes from + the plan. The backend reads with Python `zipfile` through the central directory (`worker/utils/ziputils.py`), which is + order-independent and handles data descriptors. S5 verifies against the real worker test (`test_javascript_report_e2e`) + and the viewer's unzip. If any consumer is found that needs the manifest first, fall back to a two-pass plan + (types known from the part index) at the cost of a cheap pre-scan. +* **Checksum.** `sha256Hex` is WebCrypto one-shot and nothing sends it today (`bundle-uploader.ts:29-37`), but + `upload-pipeline.ts:179` still computes it over the body. The streaming path computes it from `body.stream()` with an + incremental hash where the platform has one (`node:crypto`), and **omits** it elsewhere (it is already best-effort and + absent-tolerant). No behaviour change on the wire. + +### 4.6 Consumers of `Bundle.body` + +| Consumer | Change | +| --- | --- | +| `UploadPipeline.runOperation` | `contentLength = body.size`; `putBundle(url, body)`; checksum via stream or omitted; `dispose()` after success or terminal failure | +| `BundleUploader.putBundle` + `HttpRequest` (node `http`, browser `fetch`, webworker) | accept `Uint8Array | BundleBody`. node: pipe `body.stream()` with `Content-Length`; browser: pass the `Blob` as the fetch body. A retry calls `stream()` again | +| `BundleStore` / `serializeBundle` | durable frame `[4B hdr len][hdr JSON][zip]` unchanged on disk. `put(id, body)` writes header then pipes the body (node: file append; IndexedDB: `Blob([hdr, body])`, with an `ArrayBuffer` fallback where a runtime cannot store Blobs); `read(id)` becomes `open(id): Promise` returning a lazy body. Old frames stay readable | +| capture recovery / native-crash recovery (`capture-recovery.ts:187`, `native-crash-recovery.ts:132`) | their assembly goes through the same streaming assembler; a recovered store is the snapshot | +| `client.ts` `collectCapture`, `frozenCaptures` (report dialog) | the frozen capture becomes the **snapshot handle** plus the already-gathered `reportSnapshots` entries, not a drained Map. Holding a snapshot open pins its parts (existing contract: `unpin()`, `release()`); the dialog path must release on cancel | +| report filters (`filters.report`, `before/after`) | operate on the `Report`/`request`, not on records, so unchanged. Anything that today mutates `capturedByType` after drain is inventoried in S1 before any code moves | + +The compatibility rule: `Bundle.body` is `Uint8Array | BundleBody` for one release of the internal API and every +consumer goes through one `asBody()` normalizer, so third-party `Transport` injections keep working. + +### 4.7 Failure and cancellation + +* A throwing record encoder skips that record and reports (as `createCaptureExporter` does for a torn record today). A + failing sink aborts the export, calls `abort()` (deletes the partial artifact), and falls back to **no data** rather + than no report: the request/manifest/crash members still ship, matching "a missing snapshot must never block delivery". +* One export in flight per store, as Android's `ExportLane` (the trigger pipeline already serializes assembly, + `trigger-pipeline.ts`); a bootloop-style preemption is not a JS concept. +* The snapshot is released in a `finally`; `unpin()` when the read is abandoned (existing contract). + +## 5. Slices (each test-first, mutator loop, coverage gate, convergent review) + +| Slice | Content | Gate | +| --- | --- | --- | +| **S0** | Baseline: a bench that builds a realistic window (the BGSVIEWER-12 `replay.bin` shape + logs/network) and records peak heap and wall time of today's export, plus the fflate compressed-buffer behaviour on that data | numbers in this doc | +| **S1** | Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | +| **S2** | Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | +| **S3** | `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | +| **S4** | Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | +| **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | +| **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | + +Each slice ends with a multi-agent review round to zero new real findings (CLAUDE.md, implementation standards §7). + +## 6. Risks + +| Risk | Mitigation | +| --- | --- | +| A consumer reads `manifest.json` first | S5 check; fallback to a pre-scan plan (4.5) | +| Browser: `Blob` in IndexedDB unsupported or buggy (old Safari, private mode) | `ArrayBuffer` fallback chosen at runtime; the durable queue already tolerates a failed `put` (rejects, report retained by marker) | +| Restartable body for retry on a one-shot stream | contract says `stream()` is restartable; file and Blob are; the memory body holds chunks | +| Equal-timestamp ordering after the replay clamp | clamp preserves array order; a test with a real Replayer proves state at `start` equals the unclamped replay at the same point | +| fflate buffering grows for a type that is far from the head | bounded by compressed size; S0 measures; worst case equals today's zip size | +| The streaming path and the buffered `fileEncoders` path diverge | one shape table (4.2); equivalence tests in S2 stay as a permanent regression | +| Electron renderer streams capture to main (`streaming-capture-store`) | export happens in main from the main store; unchanged. Main-side retention limits are listed in PROGRESS.md | + +## 7. Decisions (owner, 2026-10-31: all accepted as proposed) + +* **Q1. `Bundle.body` shape.** `Uint8Array | BundleBody` transitional union (proposed) vs. a hard switch to `BundleBody`. + The union costs one normalizer and protects injected transports. +* **Q2. Record encoding.** Parse each record once and re-stringify `data` (proposed, no store change) vs. change the + store frame so the payload is spliced verbatim (fastest, touches every backend and recovery). +* **Q3. Pre-window non-replay records.** Drop them so `start` is exactly the window start (proposed) vs. keep the + ≤1 s of slack and lower `start` to the earliest kept record. +* **Q4. Node sink.** Temp file in the data dir (proposed; survives a crash for recovery and doubles as the durable queue + file) vs. memory chunks. Requires a `dataDir`; without one, memory. +* **Q5. Default cap.** 20 MB in memory / 30 MB durable (proposed), decided after S5 measurements, not before. From d5f3c7371f7299414a0dcc645801f607d22229f5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 19:39:04 +0500 Subject: [PATCH 03/33] docs(design): streaming export S0 baseline results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 98a9d21f..21638aeb 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -210,7 +210,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | Slice | Content | Gate | | --- | --- | --- | -| **S0** | Baseline: a bench that builds a realistic window (the BGSVIEWER-12 `replay.bin` shape + logs/network) and records peak heap and wall time of today's export, plus the fflate compressed-buffer behaviour on that data | numbers in this doc | +| **S0** | **DONE 2026-10-31.** Baseline bench: a realistic window (the BGSVIEWER-12 replay events + logs/network), live-heap need and wall time of today's export vs a streaming prototype; results in section 8 | numbers in this doc | | **S1** | Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | | **S2** | Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | @@ -243,3 +243,28 @@ Each slice ends with a multi-agent review round to zero new real findings (CLAUD * **Q4. Node sink.** Temp file in the data dir (proposed; survives a crash for recovery and doubles as the durable queue file) vs. memory chunks. Requires a `dataDir`; without one, memory. * **Q5. Default cap.** 20 MB in memory / 30 MB durable (proposed), decided after S5 measurements, not before. + +## 8. S0 results (2026-10-31) + +Method: a throwaway bench (not committed; esbuild-bundled, node 24, in the session scratchpad). Window: the 496 real +rrweb events of BGSVIEWER-12 (8.8 MB as JSON) + 20,000 synthetic log records + 3,000 synthetic network records (~20 MB of +serialized records in a `createMemoryCaptureStore`). Two exports of the same store: **buffered** (today: `drain()` -> +`assembleBundle` with `encodeReplay`) and a **streaming prototype** (`snapshot.stream()` -> per-type `ZipDeflate` members, +parse-once/re-stringify `data` per Q2, interleaved in store order). + +* **Peak RSS growth is the wrong metric** (66 MB streaming vs 62 MB buffered): V8 does not collect the transient garbage + until it has to, so RSS records garbage, not what must be live. +* **Live need** (smallest `--max-old-space-size` at which the export completes, setup phase confirmed done): + buffered **~70 MB**; streaming completes at **the floor of the setup itself (~44 MB)**, i.e. it adds nothing + measurable on top of the 20 MB the window already occupies. So buffered export needs **at least ~26 MB (> 1.3x the + window) of extra live heap**, which the streaming path does not. The bound for streaming is below the bench's + resolution; a finer figure needs a durable backend (the store itself then is not in the heap), which is S5's job. +* **Output and time:** 3.97 MB (buffered) vs 4.07 MB (streaming; the replay member is deflated JSON instead of the + gzip-then-deflate of `replay.bin`, so this is not yet a like-for-like size), 461-470 ms vs 450-476 ms. Streaming costs + no wall time here. +* **fflate interleaved members** behave as in section 4.1 on real data: the run above pushed three members in time order + with no temp files and a correct archive. + +Consequences for the design: the memory win is real (it is the whole export working set), and it is largest where it +matters, on the durable backends, where today's export is the **only** place the window is ever fully in the heap. +No change to the plan; S2 keeps an equivalence test against the buffered assembler and a `replay.bin` byte-format check. From b1eff8186f8362a83d55ee0e18a8bbcdb73bc442 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 19:47:03 +0500 Subject: [PATCH 04/33] feat(core): BundleBody contract and in-memory body (streaming export S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle.body is now Uint8Array | BundleBody. The upload pipeline reads it through asBody (an unreadable body is an ordinary upload failure; Content-Length is the bytes sent) and serializeBundle frames only in-memory bodies. assembleBundle returns BufferedBundle so existing callers still see raw bytes. No behaviour change for existing bundles. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- packages/core/src/bundle-assembler.ts | 5 +- packages/core/src/bundle-body.test.ts | 100 ++++++++++++++++++ packages/core/src/bundle-body.ts | 72 +++++++++++++ packages/core/src/capture-recovery.test.ts | 11 +- packages/core/src/client.test.ts | 32 +++--- .../core/src/durable-upload-pipeline.test.ts | 24 ++++- packages/core/src/durable-upload-pipeline.ts | 12 ++- packages/core/src/index.ts | 7 ++ .../core/src/native-crash-recovery.test.ts | 8 +- packages/core/src/transport.ts | 5 +- packages/core/src/upload-pipeline.test.ts | 100 +++++++++++++++++- packages/core/src/upload-pipeline.ts | 22 +++- packages/node/src/recover-instances.test.ts | 10 +- 14 files changed, 370 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/bundle-body.test.ts create mode 100644 packages/core/src/bundle-body.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 21638aeb..99832996 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -211,7 +211,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | Slice | Content | Gate | | --- | --- | --- | | **S0** | **DONE 2026-10-31.** Baseline bench: a realistic window (the BGSVIEWER-12 replay events + logs/network), live-heap need and wall time of today's export vs a streaming prototype; results in section 8 | numbers in this doc | -| **S1** | Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | +| **S1** | **DONE 2026-10-31** (two review rounds). Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | | **S2** | Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | diff --git a/packages/core/src/bundle-assembler.ts b/packages/core/src/bundle-assembler.ts index 5d53312e..91878460 100644 --- a/packages/core/src/bundle-assembler.ts +++ b/packages/core/src/bundle-assembler.ts @@ -115,11 +115,14 @@ function serializeFileData(type: FileType, payloads: unknown[]): unknown { return payloads; } +/** What the buffered assembler returns: a {@link Bundle} whose body is known to be raw bytes (narrower than `Bundle.body`). */ +export type BufferedBundle = Bundle & { body: Uint8Array }; + export function assembleBundle( request: ReportingRequest, capturedByType: Map, context: BundleAssemblyContext, -): Bundle { +): BufferedBundle { const { report, source } = request; const now = context.clock.wallNow(); diff --git a/packages/core/src/bundle-body.test.ts b/packages/core/src/bundle-body.test.ts new file mode 100644 index 00000000..48f2e3c7 --- /dev/null +++ b/packages/core/src/bundle-body.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { asBody, createMemoryBody, isBundleBody } from './bundle-body'; + +// `node:vm` through the process (this package has no node typings): a context of its own has its own Uint8Array. +const runInNewContext = (code: string): unknown => + ( + globalThis as unknown as { + process: { getBuiltinModule(id: string): { runInNewContext(code: string): unknown } }; + } + ).process + .getBuiltinModule('node:vm') + .runInNewContext(code); + +const u8 = (...n: number[]): Uint8Array => Uint8Array.from(n); +const collect = async (body: AsyncIterable): Promise => { + const out: number[] = []; + for await (const chunk of body) out.push(...chunk); + return out; +}; + +describe('createMemoryBody', () => { + it('reports the summed size of its chunks', () => { + expect(createMemoryBody([u8(1, 2), u8(3)]).size).toBe(3); + }); + + it('streams the chunks in order, and again from the start on a second call (restartable for a retry)', async () => { + const body = createMemoryBody([u8(1, 2), u8(3)]); + expect(await collect(body.stream())).toEqual([1, 2, 3]); + expect(await collect(body.stream())).toEqual([1, 2, 3]); + }); + + it('materializes to one contiguous array', async () => { + expect(Array.from(await createMemoryBody([u8(1, 2), u8(3)]).bytes())).toEqual([1, 2, 3]); + }); + + it('hands out a single chunk without copying it', async () => { + const only = u8(9, 8, 7); + const body = createMemoryBody([only]); + expect(await body.bytes()).toBe(only); + expect(body.peek()).toBe(only); + }); + + it('peeks synchronously (it is held in memory)', () => { + expect(Array.from(createMemoryBody([u8(1), u8(2)]).peek() as Uint8Array)).toEqual([1, 2]); + }); + + it('concatenates a multi-chunk body once: bytes() and peek() hand out the same array every time', async () => { + const body = createMemoryBody([u8(1), u8(2)]); + const first = await body.bytes(); + expect(await body.bytes()).toBe(first); + expect(body.peek()).toBe(first); + }); + + it('is not changed by later mutation of the chunk list it was given', async () => { + const chunks = [u8(1), u8(2)]; + const body = createMemoryBody(chunks); + chunks.push(u8(3)); + expect(body.size).toBe(2); + expect(Array.from(await body.bytes())).toEqual([1, 2]); + }); + + it('has no chunks at all as an empty body', async () => { + const body = createMemoryBody([]); + expect(body.size).toBe(0); + expect((await body.bytes()).length).toBe(0); + expect(await collect(body.stream())).toEqual([]); + }); + + it('dispose is harmless, repeatable, and leaves the body readable (nothing to release in memory)', async () => { + const body = createMemoryBody([u8(1)]); + await body.dispose(); + await body.dispose(); + expect(await collect(body.stream())).toEqual([1]); + }); +}); + +describe('asBody / isBundleBody', () => { + it('wraps raw bytes into a body of the same content', async () => { + const raw = u8(4, 5, 6); + const body = asBody(raw); + expect(body.size).toBe(3); + expect(await body.bytes()).toBe(raw); + }); + + it('passes an existing body through untouched', () => { + const body = createMemoryBody([u8(1)]); + expect(asBody(body)).toBe(body); + }); + + it('treats a Uint8Array from another realm as raw bytes, not as a body', () => { + const foreign = runInNewContext('new Uint8Array([1, 2, 3])') as Uint8Array; + expect(isBundleBody(foreign)).toBe(false); + expect(asBody(foreign).size).toBe(3); + }); + + it('tells a body from raw bytes', () => { + expect(isBundleBody(createMemoryBody([]))).toBe(true); + expect(isBundleBody(u8(1))).toBe(false); + }); +}); diff --git a/packages/core/src/bundle-body.ts b/packages/core/src/bundle-body.ts new file mode 100644 index 00000000..05055d0e --- /dev/null +++ b/packages/core/src/bundle-body.ts @@ -0,0 +1,72 @@ +// The body of an assembled bundle (docs/design/streaming-export.md §4.5). A report's `*.bundle.zip` can be large, so the +// pipeline never assumes it is one contiguous `Uint8Array`: it asks a BundleBody for what it needs (its size, a stream, +// or the bytes when something genuinely needs them all at once). Platforms back it with whatever is cheap to hold — a +// Blob, a temp file, or, here, memory chunks. + +export interface BundleBody { + /** Total length in bytes (the signed PUT's Content-Length). */ + readonly size: number; + /** The content in order. RESTARTABLE: every call reads from the start, so an upload retry can run again. */ + stream(): AsyncIterable; + /** The whole content as one array (tests, small bundles, hashing). Materializes a body that is not in memory. */ + bytes(): Promise; + /** The whole content when it is already in memory, else `undefined`: lets a synchronous writer avoid a copy. */ + peek(): Uint8Array | undefined; + /** + * Release whatever backs the body (a temp file, a Blob). Repeatable. The CREATOR of a body owns it; the upload + * pipelines do not call this yet (they arrive with the file/Blob sinks), and an in-memory body has nothing to release. + */ + dispose(): void | Promise; +} + +/** What `Bundle.body` may hold: raw bytes (every assembler today) or a {@link BundleBody}. */ +export type BundleBodyLike = Uint8Array | BundleBody; + +export function isBundleBody(value: BundleBodyLike): value is BundleBody { + // `ArrayBuffer.isView` rather than `instanceof Uint8Array`: bytes from another realm (a vm context, an iframe) are + // still bytes. + return !ArrayBuffer.isView(value); +} + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const [only] = chunks; + if (chunks.length === 1 && only !== undefined) { + return only; + } + const out = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** A body held in memory as chunks (no copy for a single chunk). The default sink's artifact. */ +export function createMemoryBody(input: readonly Uint8Array[]): BundleBody { + // Snapshot the list (the chunks themselves are not copied) so a caller reusing its array cannot change the body. + const chunks = input.slice(); + const size = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + // Joined lazily and ONCE: bytes() and peek() then hand out the same array instead of a new copy per call. + let joined: Uint8Array | undefined; + const whole = (): Uint8Array => { + joined ??= concat(chunks); + return joined; + }; + return { + size, + async *stream(): AsyncIterable { + for (const chunk of chunks) { + yield chunk; + } + }, + bytes: (): Promise => Promise.resolve(whole()), + peek: whole, + dispose: (): void => {}, + }; +} + +/** Normalize `Bundle.body` so every consumer reads one shape. */ +export function asBody(body: BundleBodyLike): BundleBody { + return isBundleBody(body) ? body : createMemoryBody([body]); +} diff --git a/packages/core/src/capture-recovery.test.ts b/packages/core/src/capture-recovery.test.ts index 7965d83d..0bbc102a 100644 --- a/packages/core/src/capture-recovery.test.ts +++ b/packages/core/src/capture-recovery.test.ts @@ -88,7 +88,7 @@ function fakePipeline(result: UploadResult = { ok: true }) { } const logsOf = (bundle: Bundle): unknown => - JSON.parse(strFromU8(unzipSync(bundle.body)['logs.json'] as Uint8Array)); + JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['logs.json'] as Uint8Array)); describe('recoverReports', () => { it('rebuilds + enqueues one bundle per marker from its generation’s chunks, then removes it', async () => { @@ -139,7 +139,10 @@ describe('recoverReports', () => { context: baseContext, uploadPipeline: pipe, }); - const files = unzipSync((pipe.bundles[0] as Bundle).body) as Record; + const files = unzipSync((pipe.bundles[0] as Bundle).body as Uint8Array) as Record< + string, + Uint8Array + >; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); const entry = manifest.files.find((f: { type: string }) => f.type === 'attachment'); expect(Array.from(files[entry.filename] as Uint8Array)).toEqual([9, 0, 255]); @@ -179,7 +182,9 @@ describe('recoverReports', () => { uploadPipeline: pipe, }); const manifest = JSON.parse( - strFromU8(unzipSync((pipe.bundles[0] as Bundle).body)['manifest.json'] as Uint8Array), + strFromU8( + unzipSync((pipe.bundles[0] as Bundle).body as Uint8Array)['manifest.json'] as Uint8Array, + ), ); expect(manifest.attrs).toEqual({ plan: 'enterprise', n: 7 }); // from the marker expect((pipe.bundles[0] as Bundle).request.email).toBe('who@x.com'); // userIdentifier → email diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 6efe5ed3..e5630c22 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -560,13 +560,13 @@ describe('createClient — file encoders', () => { }); await client.logException(new Error('x')); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - expect(unzipSync(bundle.body)['replay.bin']).toEqual(new Uint8Array([7, 1])); // encoder output, stored raw + expect(unzipSync(bundle.body as Uint8Array)['replay.bin']).toEqual(new Uint8Array([7, 1])); // encoder output, stored raw }); }); describe('createClient — crash.json', () => { const crashOf = (bundle: Bundle) => - JSON.parse(strFromU8(unzipSync(bundle.body)['crash.json'] as Uint8Array)); + JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['crash.json'] as Uint8Array)); it('logException writes a structured crash.json (handled) into the bundle', async () => { const { uploadPipeline, enqueue } = fakeUpload(); @@ -785,7 +785,7 @@ describe('createClient — crash.json', () => { const { uploadPipeline, enqueue } = fakeUpload(); const client = createClient({ uploadPipeline, appToken: 'tok', getEnvironment }); await client.logException('just a string'); - const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body); + const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body as Uint8Array); expect('crash.json' in files).toBe(true); const crash = JSON.parse(strFromU8(files['crash.json'] as Uint8Array)) as { handled: boolean; @@ -805,7 +805,7 @@ describe('createClient — report identity is snapshotted at submit time', () => // very same report. The request context was already snapshotted at submit for exactly this reason; // these two were left reading live. const assembledFrom = (enqueue: ReturnType['enqueue'], call = 0) => { - const files = unzipSync((enqueue.mock.calls[call]?.[0] as Bundle).body); + const files = unzipSync((enqueue.mock.calls[call]?.[0] as Bundle).body as Uint8Array); return { attrs: ( JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { @@ -1856,7 +1856,7 @@ describe('createClient — report path (built trigger pipeline)', () => { await client.logException(new Error('boom')); expect(snap).toHaveBeenCalledWith(1000); // pulled with the report's wall-clock timestamp const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body); + const files = unzipSync(bundle.body as Uint8Array); // viewtree.json holds the array of entry data, in source order. expect(JSON.parse(strFromU8(files['viewtree.json'] as Uint8Array))).toEqual([ { tag: 'body', children: [] }, @@ -1878,7 +1878,7 @@ describe('createClient — report path (built trigger pipeline)', () => { }); await client.logException(new Error('boom')); expect(snap).toHaveBeenCalledWith(1000); - const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body); + const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body as Uint8Array); // profile.json is the single bare object (the assembler's profile special-case). expect(JSON.parse(strFromU8(files['profile.json'] as Uint8Array))).toEqual({ nodes: [], @@ -2312,7 +2312,7 @@ describe('createClient — per-tenant capture isolation', () => { await client.logException(new Error('boom in C')); const bundle = enqueue.mock.calls[0]?.[0] as { body: Uint8Array }; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const text = Object.values(files) .map((b) => strFromU8(b)) .join('\n'); @@ -2367,7 +2367,7 @@ describe('createClient — per-tenant capture isolation', () => { client.log('SECOND'); await client.logException(new Error('boom')); const bundle = enqueue.mock.calls[0]?.[0] as { body: Uint8Array }; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const text = Object.values(files) .map((b) => strFromU8(b)) .join('\n'); @@ -2419,7 +2419,7 @@ describe('createClient — upload (manual report)', () => { }); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); const names = manifest.files .filter((f: { type: string }) => f.type === 'attachment') @@ -2446,7 +2446,7 @@ describe('createClient — upload (manual report)', () => { expect(result.ok).toBe(true); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); const names = manifest.files .filter((f: { type: string }) => f.type === 'attachment') @@ -2478,7 +2478,7 @@ describe('createClient — upload (manual report)', () => { expect(result.ok).toBe(true); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); expect( manifest.files @@ -2498,7 +2498,7 @@ describe('createClient — upload (manual report)', () => { expect(result.ok).toBe(true); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); const entry = manifest.files.find((f: { type: string }) => f.type === 'attachment'); expect(entry.attrs.fileName).toBe('x'); @@ -3142,7 +3142,7 @@ describe('createClient — upload (manual report)', () => { await client.upload(); const bundle = enqueue.mock.calls[0]?.[0] as Bundle; - const files = unzipSync(bundle.body) as Record; + const files = unzipSync(bundle.body as Uint8Array) as Record; const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)); const entry = manifest.files.find((f: { type: string }) => f.type === 'attachment'); expect(entry.attrs.mimeType).toBe('application/json'); @@ -3225,7 +3225,7 @@ describe('createClient — prepareUpload (capture frozen at preparation)', () => const log = (msg: string, at: number) => client.captureAggregator.addEntry(new CaptureDataEntryBase('log', at, { msg })); const sentLogs = (): string[] => { - const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body); + const files = unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body as Uint8Array); const raw = files['logs.json']; return raw === undefined ? [] @@ -3260,7 +3260,9 @@ describe('createClient — prepareUpload (capture frozen at preparation)', () => const request = JSON.parse( strFromU8( - unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body)['request.json'] as Uint8Array, + unzipSync((enqueue.mock.calls[0]?.[0] as Bundle).body as Uint8Array)[ + 'request.json' + ] as Uint8Array, ), ) as Record; expect(request).toMatchObject({ diff --git a/packages/core/src/durable-upload-pipeline.test.ts b/packages/core/src/durable-upload-pipeline.test.ts index 62ead6db..13fc56c5 100644 --- a/packages/core/src/durable-upload-pipeline.test.ts +++ b/packages/core/src/durable-upload-pipeline.test.ts @@ -1,6 +1,7 @@ import { type EnvironmentEnvelope, type RequestJson, Severity } from '@bugsee/protocol'; import type { AccessToken, IssueId, RecordingId } from '@bugsee/types'; import { describe, expect, it, vi } from 'vitest'; +import { createMemoryBody } from './bundle-body'; import { type BundleStore, createDurableUploadPipeline, @@ -57,6 +58,21 @@ function fakePipeline(result: UploadResult = { ok: true }) { return { pipeline: { enqueue, flush, drop } as UploadPipeline, enqueue, flush, drop }; } +describe('serializeBundle with a BundleBody', () => { + it('frames an in-memory body exactly as it frames the same raw bytes', () => { + const raw = bundle(); + const body = createMemoryBody([raw.body as Uint8Array]); + expect(Array.from(serializeBundle({ ...raw, body }, 5))).toEqual( + Array.from(serializeBundle(raw, 5)), + ); + }); + + it('refuses a body that is not held in memory (a streaming put is a later slice) instead of writing a short frame', () => { + const lazy = { ...createMemoryBody([new Uint8Array([1])]), peek: () => undefined }; + expect(() => serializeBundle({ ...bundle(), body: lazy }, 5)).toThrow(/not held in memory/); + }); +}); + describe('deserializeBundleFrame + DEFAULT_DURABLE_RETENTION', () => { // Both exist for ONE reason: the browser/worker dead-sibling recovery leg reads a dead instance's blobs // straight out of the shared store, so it never went through `recover()` and never saw the retention @@ -140,19 +156,19 @@ describe('serializeBundle / deserializeBundle', () => { const restored = deserializeBundle(serializeBundle(original)); expect(restored.request).toEqual(original.request); expect(restored.fileName).toBe(original.fileName); - expect([...restored.body]).toEqual([...original.body]); + expect([...(restored.body as Uint8Array)]).toEqual([...(original.body as Uint8Array)]); }); it('round-trips an empty body', () => { const restored = deserializeBundle(serializeBundle(bundle({ body: new Uint8Array() }))); - expect(restored.body.length).toBe(0); + expect((restored.body as Uint8Array).length).toBe(0); expect(restored.fileName).toBe('abc.bundle.zip'); }); it('round-trips a body large enough that the header length field matters', () => { const body = new Uint8Array(5000).map((_v, i) => i % 256); const restored = deserializeBundle(serializeBundle(bundle({ body }))); - expect([...restored.body]).toEqual([...body]); + expect([...(restored.body as Uint8Array)]).toEqual([...body]); }); it('deserializes from a frame embedded at a non-zero byteOffset (subarray view)', () => { @@ -161,7 +177,7 @@ describe('serializeBundle / deserializeBundle', () => { padded.set(framed, 8); const restored = deserializeBundle(padded.subarray(8)); expect(restored.request).toEqual(bundle().request); - expect([...restored.body]).toEqual([...bundle().body]); + expect([...(restored.body as Uint8Array)]).toEqual([...(bundle().body as Uint8Array)]); }); }); diff --git a/packages/core/src/durable-upload-pipeline.ts b/packages/core/src/durable-upload-pipeline.ts index dbd62571..04b540db 100644 --- a/packages/core/src/durable-upload-pipeline.ts +++ b/packages/core/src/durable-upload-pipeline.ts @@ -1,6 +1,7 @@ import type { RequestJson } from '@bugsee/protocol'; import { serviceToken } from '@bugsee/service'; import { isThenable, strFromU8, strToU8 } from '@bugsee/util'; +import { asBody } from './bundle-body'; import { type Bundle, isUploadSettled, @@ -187,10 +188,17 @@ export function serializeBundle(bundle: IdentifiedBundle, firstSeenMs?: number): ...(bundle.reportId !== undefined ? { reportId: bundle.reportId } : {}), }), ); - const out = new Uint8Array(4 + header.length + bundle.body.length); + // A synchronous writer needs the bytes now, so only a body held in memory can be framed here; a file/Blob-backed body + // needs the streaming `put` (a later slice) and must not be silently framed short. `enqueue` catches this and reports it + // (the upload still goes out, just not retained), which is the existing best-effort contract for a failed stage. + const bytes = asBody(bundle.body).peek(); + if (bytes === undefined) { + throw new Error('Bundle body is not held in memory: cannot frame it synchronously'); + } + const out = new Uint8Array(4 + header.length + bytes.length); new DataView(out.buffer).setUint32(0, header.length, true); out.set(header, 4); - out.set(bundle.body, 4 + header.length); + out.set(bytes, 4 + header.length); return out; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dd923992..bd07e4f5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,13 @@ export { } from './blackout'; export { type BugseeApiOptions, createBugseeApi } from './bugsee-api'; export { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +export { + asBody, + type BundleBody, + type BundleBodyLike, + createMemoryBody, + isBundleBody, +} from './bundle-body'; export { createBundleUploader } from './bundle-uploader'; export { type BundleFile, writeBundleZip } from './bundle-writer'; export { createCaptureAggregator } from './capture-aggregator'; diff --git a/packages/core/src/native-crash-recovery.test.ts b/packages/core/src/native-crash-recovery.test.ts index 7399447e..0655be47 100644 --- a/packages/core/src/native-crash-recovery.test.ts +++ b/packages/core/src/native-crash-recovery.test.ts @@ -130,7 +130,7 @@ describe('recoverNativeCrashes', () => { expect(result).toEqual({ harvested: 1, settled: 1, complete: true }); expect(enqueued).toHaveLength(1); - const out = unzip((enqueued[0] as Bundle).body); + const out = unzip((enqueued[0] as Bundle).body as Uint8Array); // request.json is a native crash. expect(out.request.type).toBe('crash'); expect(out.request.severity).toBe(5); // blocker @@ -287,7 +287,7 @@ describe('recoverNativeCrashes', () => { uploadPipeline: pipeline, }); - const out = unzip((enqueued[0] as Bundle).body); + const out = unzip((enqueued[0] as Bundle).body as Uint8Array); expect(out.manifest.attrs).toEqual({ build: '30.1' }); expect(out.request.email).toBe('user@x.io'); // user identifier → request.json email }); @@ -327,8 +327,8 @@ describe('recoverNativeCrashes', () => { const pipeline = { enqueue: vi.fn((bundle: Bundle) => { if ( - JSON.parse(strFromU8(unzipSync(bundle.body)['crash.json'] as Uint8Array)).minidumpFile === - 'bad.dmp' + JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['crash.json'] as Uint8Array)) + .minidumpFile === 'bad.dmp' ) { throw boom; } diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index 51c62510..4f811381 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -1,6 +1,7 @@ import type { EnvironmentEnvelope, RequestJson } from '@bugsee/protocol'; import { serviceToken } from '@bugsee/service'; import type { AccessToken, IssueId, RecordingId } from '@bugsee/types'; +import type { BundleBodyLike } from './bundle-body'; import type { BugseeError } from './errors'; // Transport split into three roles. The BugseeApi / BundleUploader / UploadPipeline @@ -140,8 +141,8 @@ export interface BundleUploader { export interface Bundle { /** The /v2/issues body, also embedded verbatim in the zip. */ request: RequestJson; - /** The `*.bundle.zip` bytes. */ - body: Uint8Array; + /** The `*.bundle.zip` content: raw bytes, or a {@link BundleBody} (a stream/Blob/file-backed artifact). */ + body: BundleBodyLike; /** Bundle archive basename `.bundle.zip`. */ fileName: string; } diff --git a/packages/core/src/upload-pipeline.test.ts b/packages/core/src/upload-pipeline.test.ts index 4c24a96c..9c33423d 100644 --- a/packages/core/src/upload-pipeline.test.ts +++ b/packages/core/src/upload-pipeline.test.ts @@ -3,8 +3,16 @@ import type { AccessToken, IssueId, RecordingId } from '@bugsee/types'; import { createDeferred, strToU8 } from '@bugsee/util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createBugseeApi } from './bugsee-api'; +import { createMemoryBody } from './bundle-body'; import { BugseeError } from './errors'; -import type { BugseeApi, Bundle, BundleUploader, IssueCreateResult, PutResult } from './transport'; +import type { + BugseeApi, + Bundle, + BundleUploader, + IssueCreateResult, + PutBundleOptions, + PutResult, +} from './transport'; import { createUploadPipeline, type PipelineOutcome, @@ -83,6 +91,96 @@ describe('createUploadPipeline — happy path', () => { }); }); + it('uploads a BundleBody: its size is the content length and its bytes are hashed and PUT', async () => { + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const sha256 = vi.fn(async () => 'cafe'); + const body = createMemoryBody([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])]); + await createUploadPipeline(deps({ uploader: fakeUploader(put), sha256 })).enqueue({ + ...bundle, + body, + }); + expect(Array.from((sha256.mock.calls[0] as unknown as [Uint8Array])[0])).toEqual([ + 1, 2, 3, 4, 5, + ]); + const [, sent, options] = put.mock.calls[0] as unknown as [ + string, + Uint8Array, + PutBundleOptions, + ]; + expect(Array.from(sent)).toEqual([1, 2, 3, 4, 5]); + expect(options).toEqual({ contentLength: 5, checksumSha256: 'cafe', fileName: 'a.bundle.zip' }); + }); + + it('a body that cannot be read fails the upload (no throw, nothing created on the collector), retryable', async () => { + const api = fakeApi(); + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const unreadable = { + ...createMemoryBody([new Uint8Array([1])]), + bytes: () => Promise.reject(new Error('temp file gone')), + }; + const result = await createUploadPipeline(deps({ api, uploader: fakeUploader(put) })).enqueue({ + ...bundle, + body: unreadable, + }); + expect(result.ok).toBe(false); + expect(result).not.toHaveProperty('permanent'); + expect((result as { error: Error }).error.message).toBe('bundle body could not be read'); + expect(api.createIssue).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + }); + + it('an unreadable body records one drop and frees its slot for the next upload', async () => { + const outcomes: unknown[] = []; + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const pipeline = createUploadPipeline( + deps({ + uploader: fakeUploader(put), + bufferSize: 1, + onOutcome: (o) => outcomes.push(o), + }), + ); + const bad = pipeline.enqueue({ + ...bundle, + body: { + ...createMemoryBody([new Uint8Array([1])]), + bytes: () => Promise.reject(new Error('x')), + }, + }); + const next = pipeline.enqueue(bundle); // waits behind the failing one (bufferSize 1) + expect((await bad).ok).toBe(false); + expect((await next).ok).toBe(true); + expect(outcomes).toContainEqual({ kind: 'drop', category: 'issue', reason: 'upload_failed' }); + expect(outcomes.filter((o) => (o as { kind: string }).kind === 'drop')).toHaveLength(1); + }); + + it('passes a BugseeError from an unreadable body through as it is, and wraps anything else with its cause', async () => { + const mk = (reason: unknown) => + createUploadPipeline(deps({})).enqueue({ + ...bundle, + body: { ...createMemoryBody([new Uint8Array([1])]), bytes: () => Promise.reject(reason) }, + }); + const own = new BugseeError('disk', 0); + expect((await mk(own)) as { error: unknown }).toMatchObject({ error: own }); + const cause = new Error('raw'); + const wrapped = (await mk(cause)) as { error: BugseeError }; + expect(wrapped.error).toBeInstanceOf(BugseeError); + expect(wrapped.error.cause).toBe(cause); + }); + + it('declares the length of the bytes it actually sends, even if the body reports another size', async () => { + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const liar = { ...createMemoryBody([new Uint8Array([1, 2, 3])]), size: 99 }; + await createUploadPipeline(deps({ uploader: fakeUploader(put) })).enqueue({ + ...bundle, + body: liar, + }); + expect( + (put.mock.calls[0] as unknown as [string, Uint8Array, PutBundleOptions])[2], + ).toMatchObject({ + contentLength: 3, + }); + }); + it('computes the checksum BEFORE creating the issue', async () => { // Issue-create is the step that leaves a record on the collector, so everything that can fail locally // runs first — and iOS sends `bundle_sha256` IN the issue-create body, which needs the digest by then. diff --git a/packages/core/src/upload-pipeline.ts b/packages/core/src/upload-pipeline.ts index e9773f77..36f28a82 100644 --- a/packages/core/src/upload-pipeline.ts +++ b/packages/core/src/upload-pipeline.ts @@ -1,4 +1,5 @@ import { computeBackoff, sha256Hex } from '@bugsee/util'; +import { asBody } from './bundle-body'; import { BugseeError } from './errors'; import { type BugseeApi, @@ -176,7 +177,22 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe const runOperation = async (bundle: Bundle, category: OutcomeCategory): Promise => { // BEFORE the issue: creating it is what leaves a record on the collector, so local work runs first — // and iOS carries `bundle_sha256` in the issue-create body itself, which needs the digest by then. - const checksumSha256 = await checksumOf(bundle.body); + // Everything below still hands the transport contiguous bytes (streaming transports are a later slice), so the body + // is materialized once here: free for an in-memory body, the one copy for any other. Done BEFORE the issue is + // created, and a read that fails is an ordinary upload failure (retryable), not a throw out of the pipeline. + let bytes: Uint8Array; + try { + bytes = await asBody(bundle.body).bytes(); + } catch (err) { + return fail( + err instanceof BugseeError + ? err + : new BugseeError('bundle body could not be read', 0, { cause: err }), + category, + 'upload_failed', + ); + } + const checksumSha256 = await checksumOf(bytes); let issue: IssueCreateResult; try { @@ -189,7 +205,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } const putOptions: PutBundleOptions = { - contentLength: bundle.body.length, + contentLength: bytes.length, fileName: bundle.fileName, ...(checksumSha256 !== undefined ? { checksumSha256 } : {}), }; @@ -206,7 +222,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } let put: PutResult; try { - put = await uploader.putBundle(endpoint, bundle.body, putOptions); + put = await uploader.putBundle(endpoint, bytes, putOptions); } catch (err) { return fail( err instanceof BugseeError diff --git a/packages/node/src/recover-instances.test.ts b/packages/node/src/recover-instances.test.ts index ae92ab4e..2b023f32 100644 --- a/packages/node/src/recover-instances.test.ts +++ b/packages/node/src/recover-instances.test.ts @@ -154,7 +154,7 @@ const fakeNativeSource = (dumps: HarvestedDump[]): NativeCrashSource & { claims: }; const crashJsonOf = (bundle: Bundle): unknown => - JSON.parse(strFromU8(unzipSync(bundle.body)['crash.json'] as Uint8Array)); + JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['crash.json'] as Uint8Array)); describe('recoverInstances', () => { it('is THROW-SAFE per subtree: an unreadable owner.json cannot abort the whole scan', async () => { @@ -831,10 +831,12 @@ describe('recoverInstances', () => { source_platform: 'node', }); // The .dmp rides as an attachment and the crashed session's capture is stitched in. - expect(Array.from(unzipSync(bundle.body)['main.dmp'] as Uint8Array)).toEqual([7, 8, 9]); - expect(JSON.parse(strFromU8(unzipSync(bundle.body)['logs.json'] as Uint8Array))).toEqual([ - { m: 'before-native-crash' }, + expect(Array.from(unzipSync(bundle.body as Uint8Array)['main.dmp'] as Uint8Array)).toEqual([ + 7, 8, 9, ]); + expect( + JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['logs.json'] as Uint8Array)), + ).toEqual([{ m: 'before-native-crash' }]); expect(source.claims).toEqual(['main.dmp']); // delivered → claimed expect(existsSync(join(dir, '9-9-dead'))).toBe(false); // fully drained → subtree removed }); From 9f87d4d71bcca2b5c8e087019917bc19c1935ec5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 20:01:23 +0500 Subject: [PATCH 05/33] feat(core): streaming bundle assembler (streaming export S2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assembleBundleStream builds the same bundle as assembleBundle (member for member, byte for byte after unzip) from an async stream of entries through a fflate streaming zip, so the window is never held as parsed objects + JSON + a one-shot archive. Shared request/manifest/ crash/attachment/shape helpers now live in bundle-assembler and serve both assemblers. CaptureExporter.stream() always releases its snapshot (also when abandoned unread), and the assembler closes its source on any failure. Not wired into the client yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- packages/core/src/bundle-assembler.ts | 164 ++++++-- .../core/src/bundle-stream-assembler.test.ts | 391 ++++++++++++++++++ packages/core/src/bundle-stream-assembler.ts | 185 +++++++++ packages/core/src/capture-exporter.test.ts | 40 ++ packages/core/src/capture-exporter.ts | 33 +- packages/core/src/index.ts | 1 + packages/util/src/fflate.test.ts | 39 +- packages/util/src/fflate.ts | 13 +- packages/util/src/index.ts | 11 +- 10 files changed, 827 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/bundle-stream-assembler.test.ts create mode 100644 packages/core/src/bundle-stream-assembler.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 99832996..d8891a2f 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -212,7 +212,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | --- | --- | --- | | **S0** | **DONE 2026-10-31.** Baseline bench: a realistic window (the BGSVIEWER-12 replay events + logs/network), live-heap need and wall time of today's export vs a streaming prototype; results in section 8 | numbers in this doc | | **S1** | **DONE 2026-10-31** (two review rounds). Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | -| **S2** | Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | +| **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | diff --git a/packages/core/src/bundle-assembler.ts b/packages/core/src/bundle-assembler.ts index 91878460..ea850247 100644 --- a/packages/core/src/bundle-assembler.ts +++ b/packages/core/src/bundle-assembler.ts @@ -64,7 +64,7 @@ export interface BundleAssemblyContext { const ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyz0123456789'; -function randomBundleFileName(): string { +export function randomBundleFileName(): string { let name = ''; for (let i = 0; i < 20; i += 1) { name += ALPHANUMERIC.charAt(Math.floor(Math.random() * ALPHANUMERIC.length)); @@ -82,10 +82,39 @@ function defaultSummary(type: IssueType): string { return 'Bug Report'; } -function fileNameForType(type: FileType): string { +export function fileNameForType(type: FileType): string { return type === 'attachment' ? 'attachment' : DEFAULT_FILENAMES[type]; } +/** + * The JSON text around the comma-joined items of one file type, for the STREAMING assembler: `open` + items + `close` + * equals `JSON.stringify(serializeFileData(type, payloads))`. `single` is the `profile` file, which is the first item + * alone (a bare object). An equivalence test pins the two against each other, so a new shape cannot be added to one. + */ +export function fileShape(type: FileType): { open: string; close: string; single: boolean } { + if (type === 'performance') { + return { open: '{"transactions":[', close: ']}', single: false }; + } + if (type === 'video.aux') { + return { + open: `{"version":${JSON.stringify(VIDEO_AUX_VERSION)},"events":[`, + close: ']}', + single: false, + }; + } + if (type === 'input') { + return { + open: `{"version":${JSON.stringify(INPUT_STREAM_VERSION)},"events":[`, + close: ']}', + single: false, + }; + } + if (type === 'profile') { + return { open: '', close: '', single: true }; + } + return { open: '[', close: ']', single: false }; +} + // Per-type JSON shape. Most file types serialize to a top-level ARRAY of payloads. Four exceptions: // `performance.json` = `{ transactions: [...] }`; `profile.json` = the SINGLE bare V8 CPU // profile object (.cpuprofile — DevTools/speedscope-loadable); and `input.json` and `video.aux.json` @@ -115,17 +144,13 @@ function serializeFileData(type: FileType, payloads: unknown[]): unknown { return payloads; } -/** What the buffered assembler returns: a {@link Bundle} whose body is known to be raw bytes (narrower than `Bundle.body`). */ -export type BufferedBundle = Bundle & { body: Uint8Array }; - -export function assembleBundle( +/** request.json: metadata from the Report + the wire source mechanism + the environment. Shared by both assemblers. */ +export function buildRequestJson( request: ReportingRequest, - capturedByType: Map, context: BundleAssemblyContext, -): BufferedBundle { + now: number, +): RequestJson { const { report, source } = request; - const now = context.clock.wallNow(); - // Wire `email` = a per-report email if set, else the request-context user (when the report fired in a // request), else the global user identifier (Android "email from global scope"). Emitted only when it // is a non-empty string. @@ -136,7 +161,7 @@ export function assembleBundle( report.email ?? (requestContext?.user || undefined) ?? context.userIdentifier ?? undefined; // request.json: metadata from the Report + the wire source mechanism + the environment. - const requestJson: RequestJson = { + return { type: report.type, summary: report.summary ?? defaultSummary(report.type), severity: severityToWire(report.severity), @@ -158,37 +183,20 @@ export function assembleBundle( ...(email !== undefined && email !== '' ? { email } : {}), ...(report.signatures.length > 0 ? { signatures: report.signatures } : {}), }; +} - // Time bounds: earliest captured entry → trigger moment. - let start = now; +/** + * The per-report members that are not a rolling capture stream: the crash detail, the report's attachments and the + * application's attachments. Shared by both assemblers. `dataNames` are the bundle member names already taken by the + * captured data (an attachment must not clash with one). + */ +export function buildReportMembers( + report: ReportingRequest['report'], + context: BundleAssemblyContext, + dataNames: readonly string[], +): { files: ManifestFileEntry[]; typedFiles: BundleFile[] } { const files: ManifestFileEntry[] = []; const typedFiles: BundleFile[] = []; - for (const [type, entries] of capturedByType) { - for (const entry of entries) { - if (entry.timestamp < start) { - start = entry.timestamp; - } - } - const filename = fileNameForType(type); - files.push({ - filename, - type, - // A video entry with no attrs reads as the default — an h264 MP4 — to the worker (which then skips the - // transcode a WebM needs) and to the viewer (which labels it `video/mp4`). The container is whatever - // the file name says it is. - ...(type === 'video' - ? { attrs: { format: filename.slice(filename.lastIndexOf('.') + 1) } } - : {}), - }); - const payloads = entries.map((entry) => entry.data); - // A BINARY file type (e.g. `replay` → gzipped `replay.bin`) goes through its injected encoder → bytes; - // every other type is JSON. Core stays codec-free — the encoder is provided by the platform/extension. - const encoder = context.fileEncoders?.[type]; - const data = - encoder !== undefined ? encoder(payloads) : JSON.stringify(serializeFileData(type, payloads)); - typedFiles.push({ name: filename, data }); - } - // Structured crash detail (`crash.json`) — a per-report file (not a rolling capture stream) built // from the thrown Error at report time. The backend crash pipeline reads it for stack symbolication. // @@ -214,6 +222,7 @@ export function assembleBundle( REQUEST_JSON_FILENAME, MANIFEST_JSON_FILENAME, APP_TOKEN_FILENAME, + ...dataNames, ...typedFiles.map((file) => file.name), ]); for (const attachment of report.attachments ?? []) { @@ -247,18 +256,83 @@ export function assembleBundle( }); typedFiles.push({ name: att.bundleName, data: att.data }); } + return { files, typedFiles }; +} - // Manifest attributes: the request context's attributes (when present) merged OVER the global ones. +/** manifest.json: the file inventory, the capture window and the attributes. Shared by both assemblers. */ +export function buildManifest( + context: BundleAssemblyContext, + start: number, + end: number, + files: ManifestFileEntry[], +): ManifestJson { + // The request context's attributes (when present) merged OVER the global ones. + const requestContext = context.requestContext; const attrs = requestContext?.attributes !== undefined ? { ...context.attributes, ...requestContext.attributes } : context.attributes; - const manifest: ManifestJson = { - version: MANIFEST_VERSION, - time: { start, end: now }, - files, - attrs, + return { version: MANIFEST_VERSION, time: { start, end }, files, attrs }; +} + +/** The manifest entry of one captured data type. Shared by both assemblers. */ +export function manifestEntryForType(type: FileType): ManifestFileEntry { + const filename = fileNameForType(type); + return { + filename, + type, + // A video entry with no attrs reads as the default — an h264 MP4 — to the worker (which then skips the + // transcode a WebM needs) and to the viewer (which labels it `video/mp4`). The container is whatever + // the file name says it is. + ...(type === 'video' + ? { attrs: { format: filename.slice(filename.lastIndexOf('.') + 1) } } + : {}), }; +} + +/** What the buffered assembler returns: a {@link Bundle} whose body is known to be raw bytes (narrower than `Bundle.body`). */ +export type BufferedBundle = Bundle & { body: Uint8Array }; + +export function assembleBundle( + request: ReportingRequest, + capturedByType: Map, + context: BundleAssemblyContext, +): BufferedBundle { + const { report } = request; + const now = context.clock.wallNow(); + + const requestJson = buildRequestJson(request, context, now); + + // Time bounds: earliest captured entry → trigger moment. + let start = now; + const files: ManifestFileEntry[] = []; + const typedFiles: BundleFile[] = []; + for (const [type, entries] of capturedByType) { + for (const entry of entries) { + if (entry.timestamp < start) { + start = entry.timestamp; + } + } + const filename = fileNameForType(type); + files.push(manifestEntryForType(type)); + const payloads = entries.map((entry) => entry.data); + // A BINARY file type (e.g. `replay` → gzipped `replay.bin`) goes through its injected encoder → bytes; + // every other type is JSON. Core stays codec-free — the encoder is provided by the platform/extension. + const encoder = context.fileEncoders?.[type]; + const data = + encoder !== undefined ? encoder(payloads) : JSON.stringify(serializeFileData(type, payloads)); + typedFiles.push({ name: filename, data }); + } + + const reportMembers = buildReportMembers( + report, + context, + typedFiles.map((file) => file.name), + ); + files.push(...reportMembers.files); + typedFiles.push(...reportMembers.typedFiles); + + const manifest = buildManifest(context, start, now, files); const body = writeBundleZip([ { name: REQUEST_JSON_FILENAME, data: JSON.stringify(requestJson) }, diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts new file mode 100644 index 00000000..6e94db49 --- /dev/null +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -0,0 +1,391 @@ +import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +import { assembleBundleStream } from './bundle-stream-assembler'; +import { CaptureDataEntryBase } from './capture-data-entry'; +import type { CaptureDataEntry } from './contracts'; +import type { CrashJson } from './crash'; +import { createReportingRequest } from './reporting'; + +// The streaming assembler must produce a bundle that unzips to EXACTLY what the buffered one does (member names, +// bytes, manifest), for the same captured data — that is what makes it a drop-in. Member ORDER inside the zip and the +// order of the manifest's `files` are not part of the contract (the worker reads the central directory), so both are +// compared as sets. + +const env: EnvironmentEnvelope = { + platform: { type: 'web', version: '124' }, + runtime: { type: 'web', version: '' }, + sdk: { version: '1.0.0', type: 'javascript' }, +}; +const context = (over: Partial = {}): BundleAssemblyContext => ({ + appToken: 'tok_123', + environment: env, + attributes: { a: 'b' }, + clock: { wallNow: () => 1_700_000_000_000, monotonicNow: () => 0 }, + fileName: () => 'fixed.bundle.zip', + ...over, +}); +const entry = (type: FileType, timestamp: number, data: unknown): CaptureDataEntry => + new CaptureDataEntryBase(type, timestamp, data); + +async function* from(entries: CaptureDataEntry[]): AsyncIterable { + for (const e of entries) yield e; +} +const group = (entries: CaptureDataEntry[]): Map => { + const map = new Map(); + for (const e of entries) map.set(e.type, [...(map.get(e.type) ?? []), e]); + return map; +}; + +// Both assemblers draw random names (user attachments, the default file name): feed them the same sequence. +function seedRandom(): void { + let n = 0; + vi.spyOn(Math, 'random').mockImplementation(() => ((n++ * 0.137) % 1) as number); +} +afterEach(() => vi.restoreAllMocks()); + +const members = (body: Uint8Array) => unzipSync(body) as Record; +const normalized = (files: Record) => { + const out: Record = {}; + for (const [name, bytes] of Object.entries(files)) { + if (name === 'manifest.json') { + const manifest = JSON.parse(strFromU8(bytes)) as { files: Array<{ filename: string }> }; + manifest.files.sort((a, b) => a.filename.localeCompare(b.filename)); + out[name] = manifest; + } else { + out[name] = Array.from(bytes); + } + } + return out; +}; + +async function bothWays( + entries: CaptureDataEntry[], + request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + over: Partial = {}, +) { + seedRandom(); + const buffered = assembleBundle(request, group(entries), context(over)); + vi.restoreAllMocks(); + seedRandom(); + const streamed = await assembleBundleStream(request, from(entries), context(over)); + const streamedBytes = await streamed.body.bytes(); + return { + buffered, + streamed, + a: normalized(members(buffered.body)), + b: normalized(members(streamedBytes)), + }; +} + +describe('assembleBundleStream — equals the buffered assembler', () => { + it('for plain JSON types, interleaved in time order', async () => { + const r = await bothWays([ + entry('log', 1, { m: 'a' }), + entry('network', 2, { url: 'u' }), + entry('log', 3, { m: 'b' }), + entry('events.user', 4, { name: 'e' }), + ]); + expect(r.b).toEqual(r.a); + expect(Object.keys(r.b)).toEqual( + expect.arrayContaining(['logs.json', 'request.json', 'apptoken']), + ); + }); + + it('for every wrapped shape: performance, input, video.aux, and the single-object profile', async () => { + const r = await bothWays([ + entry('performance', 1, { n: 1 }), + entry('performance', 2, { n: 2 }), + entry('input', 3, { k: 1 }), + entry('input', 4, { k: 2 }), + entry('video.aux', 5, { f: 1 }), + entry('video.aux', 6, { f: 2 }), + entry('profile', 7, { nodes: [1] }), + ]); + expect(r.b).toEqual(r.a); + }); + + it('keeps only the FIRST profile, as the buffered assembler does', async () => { + const r = await bothWays([ + entry('profile', 1, { first: true }), + entry('profile', 2, { second: true }), + ]); + expect(r.b).toEqual(r.a); + expect(strFromU8(unzipSync(await r.streamed.body.bytes())['profile.json'] as Uint8Array)).toBe( + '{"first":true}', + ); + }); + + it('for an entry whose data is undefined (it must read null inside the array, not break the JSON)', async () => { + const r = await bothWays([entry('log', 1, undefined), entry('log', 2, { a: 1 })]); + expect(r.b).toEqual(r.a); + expect(strFromU8(unzipSync(await r.streamed.body.bytes())['logs.json'] as Uint8Array)).toBe( + '[null,{"a":1}]', + ); + }); + + it('with nothing captured: request.json, manifest.json and apptoken only', async () => { + const r = await bothWays([]); + expect(r.b).toEqual(r.a); + expect(Object.keys(r.b).sort()).toEqual(['apptoken', 'manifest.json', 'request.json']); + }); + + it('for a binary type routed through its injected encoder (replay -> bytes)', async () => { + const fileEncoders = { replay: (payloads: unknown[]) => Uint8Array.from([7, payloads.length]) }; + const r = await bothWays( + [entry('replay', 1, { e: 1 }), entry('log', 2, {}), entry('replay', 3, { e: 2 })], + undefined, + { fileEncoders }, + ); + expect(r.b).toEqual(r.a); + expect( + Array.from(unzipSync(await r.streamed.body.bytes())['replay.bin'] as Uint8Array), + ).toEqual([7, 2]); + }); + + it('for a video entry (its container format rides in the manifest)', async () => { + const r = await bothWays([entry('video', 1, { x: 1 })]); + expect(r.b).toEqual(r.a); + }); + + it('with a crash, report attachments and user attachments', async () => { + const crash: CrashJson = { + exception_type: 'error', + ndkCrash: false, + handled: true, + exception: { name: 'TypeError', reason: 'boom', frames: [] }, + }; + const request = createReportingRequest({ + source: { type: 'error' }, + id: 'r1', + crash, + attachments: [{ name: 'dump-1.dmp', data: new Uint8Array([1, 2, 3]) }], + userAttachments: [{ name: 'note', data: 'héllo' }], + }); + const r = await bothWays([entry('log', 1, { m: 1 })], request); + expect(r.b).toEqual(r.a); + expect(Object.keys(r.b)).toEqual(expect.arrayContaining(['crash.json', 'dump-1.dmp'])); + }); + + it('skips an attachment whose name clashes with a captured data member, and reports it', async () => { + const request = createReportingRequest({ + source: { type: 'error' }, + id: 'r1', + attachments: [{ name: 'logs.json', data: new Uint8Array([1]) }], + }); + const errors: unknown[] = []; + const r = await bothWays([entry('log', 1, {})], request, { onError: (e) => errors.push(e) }); + expect(r.b).toEqual(r.a); + expect(errors.length).toBeGreaterThan(0); + }); + + it('with the request context user and attributes (manifest attrs and request.json)', async () => { + const r = await bothWays([entry('log', 1, {})], undefined, { + requestContext: { contextId: 'c1', user: 'u@x.y', attributes: { k: 'v' } }, + }); + expect(r.b).toEqual(r.a); + }); + + it('returns the same request and file name', async () => { + const r = await bothWays([entry('log', 1, {})]); + expect(r.streamed.request).toEqual(r.buffered.request); + expect(r.streamed.fileName).toBe('fixed.bundle.zip'); + }); + + it('defaults the file name like the buffered assembler (.bundle.zip)', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const streamed = await assembleBundleStream( + request, + from([]), + context({ fileName: undefined }), + ); + expect(streamed.fileName).toMatch(/^[a-z0-9]{20}\.bundle\.zip$/); + }); +}); + +describe('assembleBundleStream — time bounds and merging', () => { + it('sets time.start to the earliest timestamp across the stream, the encoder types and the extra entries', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const fileEncoders = { replay: (p: unknown[]) => Uint8Array.from([p.length]) }; + const streamed = await assembleBundleStream( + request, + from([entry('log', 500, {}), entry('replay', 300, {})]), + context({ fileEncoders }), + new Map([['viewtree', [entry('viewtree', 100, {})]]]), + ); + const manifest = JSON.parse( + strFromU8(unzipSync(await streamed.body.bytes())['manifest.json'] as Uint8Array), + ); + expect(manifest.time).toEqual({ start: 100, end: 1_700_000_000_000 }); + }); + + it('merges report-time snapshot entries into the same member as the streamed ones, after them', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const streamed = await assembleBundleStream( + request, + from([entry('log', 1, { s: 1 })]), + context(), + new Map([ + ['log', [entry('log', 9, { x: 1 })]], + ['viewtree', [entry('viewtree', 9, { v: 1 })]], + ]), + ); + const files = unzipSync(await streamed.body.bytes()); + expect(strFromU8(files['logs.json'] as Uint8Array)).toBe('[{"s":1},{"x":1}]'); + expect(Object.keys(files)).toContain('viewtree.json'); + }); +}); + +describe('assembleBundleStream — failure', () => { + it('refuses a duplicate member name exactly as the buffered assembler does (never writes two crash.json)', async () => { + const crash: CrashJson = { + exception_type: 'error', + ndkCrash: false, + handled: true, + exception: { name: 'E', reason: 'r', frames: [] }, + }; + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1', crash }); + const entries = [entry('crash', 1, { x: 1 })]; + expect(() => assembleBundle(request, group(entries), context())).toThrow( + /duplicate file name: crash.json/, + ); + await expect(assembleBundleStream(request, from(entries), context())).rejects.toThrow( + /duplicate file name: crash.json/, + ); + }); + + it('closes the source even when the failure comes BEFORE its first entry is read (the snapshot is never leaked)', async () => { + let closed = 0; + const source: AsyncIterableIterator = { + next: async () => ({ done: true, value: undefined }), + return: async () => { + closed += 1; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + // A signature that cannot be serialized makes request.json throw before the data pass starts. + const request = createReportingRequest({ + source: { type: 'error' }, + id: 'r1', + signatures: [{ n: 1n } as never], + }); + await expect(assembleBundleStream(request, source, context())).rejects.toThrow(); + expect(closed).toBe(1); + }); + + it('closes the source when the failure comes from building request.json itself (a bad clock)', async () => { + let acquired = 0; + let closed = 0; + const source: AsyncIterableIterator = { + next: async () => ({ done: true, value: undefined }), + return: async () => { + closed += 1; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + acquired += 1; + return this; + }, + }; + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + await expect( + assembleBundleStream( + request, + source, + context({ clock: { wallNow: () => Number.NaN, monotonicNow: () => 0 } }), + ), + ).rejects.toThrow(RangeError); + expect([acquired, closed]).toEqual([1, 1]); + }); + + it('copes with a source iterator that has no return()', async () => { + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }), + }; + const request = createReportingRequest({ + source: { type: 'error' }, + id: 'r1', + signatures: [{ n: 1n } as never], + }); + await expect(assembleBundleStream(request, source, context())).rejects.toThrow(/BigInt/); + }); + + it('reports the ORIGINAL failure when closing the source fails too', async () => { + const source: AsyncIterableIterator = { + next: async () => ({ done: true, value: undefined }), + return: async () => { + throw new Error('release failed'); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + const request = createReportingRequest({ + source: { type: 'error' }, + id: 'r1', + signatures: [{ n: 1n } as never], + }); + await expect(assembleBundleStream(request, source, context())).rejects.toThrow(/BigInt/); + }); + + it('closes the source when a mid-pass failure ends the loop (closing is idempotent, so a second call is harmless)', async () => { + let closed = 0; + let served = false; + const source: AsyncIterableIterator = { + next: async () => { + if (served) return { done: true, value: undefined }; + served = true; + return { done: false, value: entry('log', 1, { n: 10n }) }; // serializing it throws + }, + return: async () => { + closed += 1; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + await expect(assembleBundleStream(request, source, context())).rejects.toThrow(); + expect(closed).toBeGreaterThanOrEqual(1); + }); + + it('closes the source exactly once when reading it fails (only the outer cleanup can do that)', async () => { + let closed = 0; + const source: AsyncIterableIterator = { + next: async () => { + throw new Error('read failed'); + }, + return: async () => { + closed += 1; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + await expect(assembleBundleStream(request, source, context())).rejects.toThrow('read failed'); + expect(closed).toBe(1); + }); + + it('rejects, like the buffered assembler throws, when an entry cannot be serialized (BigInt)', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const entries = [entry('log', 1, { n: 10n })]; + expect(() => assembleBundle(request, group(entries), context())).toThrow(); + await expect(assembleBundleStream(request, from(entries), context())).rejects.toThrow(); + }); + + it('rejects when the source fails mid-stream, instead of returning a truncated bundle', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + async function* broken(): AsyncIterable { + yield entry('log', 1, {}); + throw new Error('read failed'); + } + await expect(assembleBundleStream(request, broken(), context())).rejects.toThrow('read failed'); + }); +}); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts new file mode 100644 index 00000000..6577cf1f --- /dev/null +++ b/packages/core/src/bundle-stream-assembler.ts @@ -0,0 +1,185 @@ +import { + APP_TOKEN_FILENAME, + type FileType, + MANIFEST_JSON_FILENAME, + type ManifestFileEntry, + REQUEST_JSON_FILENAME, +} from '@bugsee/protocol'; +import { createDeferred, strToU8, Zip, ZipDeflate } from '@bugsee/util'; +import { + type BundleAssemblyContext, + buildManifest, + buildReportMembers, + buildRequestJson, + fileNameForType, + fileShape, + manifestEntryForType, + randomBundleFileName, +} from './bundle-assembler'; +import { type BundleBody, createMemoryBody } from './bundle-body'; +import type { CaptureDataEntry } from './contracts'; +import type { ReportingRequest } from './reporting'; +import type { Bundle } from './transport'; + +// The STREAMING report assembler (docs/design/streaming-export.md §4.1). It builds the same bundle as `assembleBundle` +// — member for member, byte for byte after unzipping — but reads the captured data one entry at a time and feeds it to a +// streaming zip, so the whole window is never held as parsed objects, JSON strings and a one-shot archive at once. +// A member that is not at the head of the zip is compressed as it arrives and waits compressed; peak memory is the +// compressed size of the window, not the window. +// +// Everything that is not a rolling capture stream (request.json, manifest.json, the crash, the attachments) is built by +// the helpers the buffered assembler exports, so the two cannot drift. File types with an injected binary encoder (the +// replay) still collect their payloads and encode at the end; a streaming encoder for them is a later slice. + +interface StreamMember { + readonly file: ZipDeflate; + readonly shape: ReturnType; + count: number; +} + +/** `JSON.stringify` that never returns `undefined`: inside an array an undefined item reads `null`. */ +function jsonItem(value: unknown): string { + return JSON.stringify(value) ?? 'null'; +} + +export async function assembleBundleStream( + request: ReportingRequest, + entries: AsyncIterable, + context: BundleAssemblyContext, + extra?: ReadonlyMap, +): Promise { + // The source is acquired FIRST and closed on ANY failure below: it may hold a frozen snapshot (the capture exporter's + // stream does), and a generator that was never read would otherwise never run its own cleanup. + const iterator = entries[Symbol.asyncIterator](); + try { + return await build(request, iterator, context, extra); + } catch (error) { + try { + await iterator.return?.(); + } catch { + // Closing the source failed too: the caller needs the ORIGINAL failure, not this one. + } + throw error; + } +} + +async function build( + request: ReportingRequest, + iterator: AsyncIterator, + context: BundleAssemblyContext, + extra: ReadonlyMap | undefined, +): Promise { + const now = context.clock.wallNow(); + const requestJson = buildRequestJson(request, context, now); + + const chunks: Uint8Array[] = []; + const done = createDeferred(); + const zip = new Zip((error, data, final) => { + /* v8 ignore next 4 -- fflate's streaming deflate is synchronous and reports no error for in-memory input; the guard keeps the contract if that ever changes */ + if (error !== null) { + done.reject(error); + return; + } + chunks.push(data); + if (final) { + done.resolve(); + } + }); + + // Every member name written so far: a clash (a captured `crash` entry beside report.crash, say) is refused exactly as + // `writeBundleZip` refuses it, rather than writing two members of one name. + const claimed = new Set(); + const claim = (name: string): string => { + if (claimed.has(name)) { + throw new Error(`Bundle has a duplicate file name: ${name}`); + } + claimed.add(name); + return name; + }; + const addWhole = (name: string, data: Uint8Array | string): void => { + const file = new ZipDeflate(claim(name)); + zip.add(file); + file.push(typeof data === 'string' ? strToU8(data) : data, true); + }; + + addWhole(REQUEST_JSON_FILENAME, JSON.stringify(requestJson)); + addWhole(APP_TOKEN_FILENAME, context.appToken); + + // The data pass. `order` is the first-seen order of the types (the manifest lists them in it). + let start = now; + const order: FileType[] = []; + const streamed = new Map(); + const collected = new Map(); // types with an injected encoder + const take = (entry: CaptureDataEntry): void => { + const type = entry.type; + if (entry.timestamp < start) { + start = entry.timestamp; + } + if (!streamed.has(type) && !collected.has(type)) { + order.push(type); + if (context.fileEncoders?.[type] !== undefined) { + collected.set(type, []); + } else { + const file = new ZipDeflate(claim(fileNameForType(type))); + zip.add(file); + streamed.set(type, { file, shape: fileShape(type), count: 0 }); + } + } + const payloads = collected.get(type); + if (payloads !== undefined) { + payloads.push(entry.data); + return; + } + const member = streamed.get(type) as StreamMember; + if (member.shape.single) { + // `profile`: the first entry alone, as a bare object. + if (member.count === 0) { + member.file.push(strToU8(jsonItem(entry.data)), false); + } + } else { + member.file.push( + strToU8((member.count === 0 ? member.shape.open : ',') + jsonItem(entry.data)), + false, + ); + } + member.count += 1; + }; + for await (const entry of { [Symbol.asyncIterator]: () => iterator }) { + take(entry); + } + for (const list of extra?.values() ?? []) { + for (const entry of list) { + take(entry); + } + } + + const files: ManifestFileEntry[] = []; + const dataNames: string[] = []; + for (const type of order) { + files.push(manifestEntryForType(type)); + dataNames.push(fileNameForType(type)); + const member = streamed.get(type); + if (member !== undefined) { + member.file.push(strToU8(member.shape.close), true); + } else { + const encode = (context.fileEncoders as NonNullable)[type] as ( + payloads: unknown[], + ) => Uint8Array; + addWhole(fileNameForType(type), encode(collected.get(type) as unknown[])); + } + } + + const reportMembers = buildReportMembers(request.report, context, dataNames); + for (const member of reportMembers.typedFiles) { + addWhole(member.name, member.data); + } + files.push(...reportMembers.files); + addWhole(MANIFEST_JSON_FILENAME, JSON.stringify(buildManifest(context, start, now, files))); + zip.end(); + await done.promise; + return { + request: requestJson, + body: createMemoryBody(chunks), + fileName: (context.fileName ?? randomBundleFileName)(), + }; +} diff --git a/packages/core/src/capture-exporter.test.ts b/packages/core/src/capture-exporter.test.ts index b345d68d..23163339 100644 --- a/packages/core/src/capture-exporter.test.ts +++ b/packages/core/src/capture-exporter.test.ts @@ -72,6 +72,46 @@ describe('createCaptureExporter — stream', () => { expect(fake.isReleased()).toBe(true); }); + it('releases the snapshot when the stream is abandoned BEFORE it was ever read', async () => { + const fake = fakeStore([rec('log', 1, {})]); + const it = createCaptureExporter(fake.store).stream(); + await it.return?.(undefined); // an unstarted async generator would skip its own finally + expect(fake.isReleased()).toBe(true); + }); + + it('releases once, and a second return() is harmless', async () => { + const release = vi.fn(); + const store: CaptureStore = { + add: () => {}, + tick: () => {}, + clear: () => {}, + snapshot: () => ({ + stream: async function* () {}, + drainAll: async () => new Map(), + release, + }), + }; + const it = createCaptureExporter(store).stream(); + await it.return?.(undefined); + await it.return?.(undefined); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('releases the snapshot when a consumer throws into a stream that was never read', async () => { + const fake = fakeStore([rec('log', 1, {})]); + const it = createCaptureExporter(fake.store).stream(); + await expect(it.throw?.(new Error('consumer failed'))).rejects.toThrow('consumer failed'); + expect(fake.isReleased()).toBe(true); + }); + + it('releases the snapshot when a consumer throws into the stream mid-read', async () => { + const fake = fakeStore([rec('log', 1, {}), rec('log', 2, {})]); + const it = createCaptureExporter(fake.store).stream(); + await it.next(); + await expect(it.throw?.(new Error('consumer failed'))).rejects.toThrow('consumer failed'); + expect(fake.isReleased()).toBe(true); + }); + it('yields nothing for an empty snapshot', async () => { const seen: unknown[] = []; for await (const e of createCaptureExporter(fakeStore([]).store).stream()) { diff --git a/packages/core/src/capture-exporter.ts b/packages/core/src/capture-exporter.ts index 23ac9ca1..003202aa 100644 --- a/packages/core/src/capture-exporter.ts +++ b/packages/core/src/capture-exporter.ts @@ -30,7 +30,17 @@ export function createCaptureExporter( stream(options?: { owner?: string }): AsyncIterableIterator { const snapshot = store.snapshot(options); const records = snapshot.stream(); - return (async function* (): AsyncIterableIterator { + // The snapshot is taken NOW (it freezes the window at the moment of the call), so its release must not depend on the + // generator below ever starting: an async generator that was never read skips its own `finally`, and a consumer + // that fails before its first read would leak the frozen copy. `return()` therefore always releases, once. + let released = false; + const release = (): void => { + if (!released) { + released = true; + snapshot.release(); + } + }; + const inner = (async function* (): AsyncGenerator { try { for await (const record of records) { let entry: CaptureDataEntry; @@ -45,9 +55,28 @@ export function createCaptureExporter( yield entry; } } finally { - snapshot.release(); + release(); } })(); + const stream: AsyncIterableIterator = { + next: () => inner.next(), + return: async (value) => { + try { + return await inner.return(value); + } finally { + release(); + } + }, + throw: async (error) => { + try { + return await inner.throw(error); + } finally { + release(); + } + }, + [Symbol.asyncIterator]: () => stream, + }; + return stream; }, async drain(options?: { owner?: string }): Promise> { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bd07e4f5..3e641a3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -63,6 +63,7 @@ export { createMemoryBody, isBundleBody, } from './bundle-body'; +export { assembleBundleStream } from './bundle-stream-assembler'; export { createBundleUploader } from './bundle-uploader'; export { type BundleFile, writeBundleZip } from './bundle-writer'; export { createCaptureAggregator } from './capture-aggregator'; diff --git a/packages/util/src/fflate.test.ts b/packages/util/src/fflate.test.ts index c8b3e3b8..0acc0ab5 100644 --- a/packages/util/src/fflate.test.ts +++ b/packages/util/src/fflate.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { gunzipSync, gzipSync, strFromU8, strToU8, unzipSync, zipSync } from './fflate'; +import { + gunzipSync, + gzipSync, + strFromU8, + strToU8, + unzipSync, + Zip, + ZipDeflate, + zipSync, +} from './fflate'; // Integration test across the @bugsee/util -> fflate boundary: the round-trips // fail if any re-exported binding is wrong or missing. @@ -15,3 +24,31 @@ describe('fflate re-export', () => { expect(strFromU8(out['a.txt'] ?? new Uint8Array())).toBe('content'); }); }); + +describe('streaming zip (the export path depends on this fflate behaviour)', () => { + it('accepts pushes to several members in INTERLEAVED order and still writes a valid archive', () => { + const chunks: Uint8Array[] = []; + const zip = new Zip((error, data) => { + if (error) throw error; + chunks.push(data); + }); + const a = new ZipDeflate('a.json'); + const b = new ZipDeflate('b.json'); + zip.add(a); + zip.add(b); + b.push(strToU8('B1'), false); // the later member is pushed first + a.push(strToU8('A1'), false); + b.push(strToU8('B2'), true); + a.push(strToU8('A2'), true); + zip.end(); + const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const c of chunks) { + out.set(c, at); + at += c.length; + } + const files = unzipSync(out); + expect(strFromU8(files['a.json'] as Uint8Array)).toBe('A1A2'); + expect(strFromU8(files['b.json'] as Uint8Array)).toBe('B1B2'); + }); +}); diff --git a/packages/util/src/fflate.ts b/packages/util/src/fflate.ts index 6f5967d8..dc1ed73b 100644 --- a/packages/util/src/fflate.ts +++ b/packages/util/src/fflate.ts @@ -1,7 +1,16 @@ /** - * Re-export of the fflate subset the SDK uses: gzip for the replay stream and zip for bundle + * Re-export of the fflate subset the SDK uses: gzip for the replay stream, zip (one-shot and streaming) for bundle * assembly. Keeps fflate a single, version-pinned dependency surface so the * rest of the SDK imports compression through `@bugsee/util` rather than depending on fflate * directly. */ -export { gunzipSync, gzipSync, strFromU8, strToU8, unzipSync, zipSync } from 'fflate'; +export { + gunzipSync, + gzipSync, + strFromU8, + strToU8, + unzipSync, + Zip, + ZipDeflate, + zipSync, +} from 'fflate'; diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index 9f94bb8e..26c62eab 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -21,7 +21,16 @@ export { isVercelEdge, isWebWorker, } from './env'; -export { gunzipSync, gzipSync, strFromU8, strToU8, unzipSync, zipSync } from './fflate'; +export { + gunzipSync, + gzipSync, + strFromU8, + strToU8, + unzipSync, + Zip, + ZipDeflate, + zipSync, +} from './fflate'; export { jsonSafeStringify } from './json-safe-stringify'; export { randomId } from './random-id'; export { reportGuarded } from './report-guarded'; From 0e612b3543d227b0847cb6b60e609d790b13f0d7 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 22:49:21 +0500 Subject: [PATCH 06/33] feat(core): time plan for streaming export (S3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaptureSnapshot.timeRange() from the chunk store (window ending now, clipped to the store epoch), exposed on the exporter stream; the streaming assembler takes manifest time.start from it and drops rolling non-encoder records older than it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- .../core/src/bundle-stream-assembler.test.ts | 136 ++++++++++++++++++ packages/core/src/bundle-stream-assembler.ts | 29 ++-- packages/core/src/capture-exporter.test.ts | 21 +++ packages/core/src/capture-exporter.ts | 7 +- packages/core/src/chunk-capture-store.test.ts | 135 +++++++++++++++++ packages/core/src/chunk-capture-store.ts | 29 +++- packages/core/src/contracts.ts | 14 +- packages/core/src/index.ts | 1 + .../src/partitioned-capture-store.test.ts | 49 +++++++ .../core/src/partitioned-capture-store.ts | 10 ++ 11 files changed, 418 insertions(+), 15 deletions(-) diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index d8891a2f..6ca37291 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -213,7 +213,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S0** | **DONE 2026-10-31.** Baseline bench: a realistic window (the BGSVIEWER-12 replay events + logs/network), live-heap need and wall time of today's export vs a streaming prototype; results in section 8 | numbers in this doc | | **S1** | **DONE 2026-10-31** (two review rounds). Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | -| **S3** | `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | +| **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 6e94db49..9b92ee80 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -4,8 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; import { assembleBundleStream } from './bundle-stream-assembler'; import { CaptureDataEntryBase } from './capture-data-entry'; +import { createCaptureExporter } from './capture-exporter'; +import { createChunkCaptureStore } from './chunk-capture-store'; import type { CaptureDataEntry } from './contracts'; import type { CrashJson } from './crash'; +import { createMemoryChunkBackend } from './memory-chunk-backend'; import { createReportingRequest } from './reporting'; // The streaming assembler must produce a bundle that unzips to EXACTLY what the buffered one does (member names, @@ -389,3 +392,136 @@ describe('assembleBundleStream — failure', () => { await expect(assembleBundleStream(request, broken(), context())).rejects.toThrow('read failed'); }); }); + +describe('assembleBundleStream — time plan', () => { + const withRange = ( + entries: CaptureDataEntry[], + range: { start: number; end: number } | undefined, + ): AsyncIterable & { timeRange?: { start: number; end: number } } => + Object.assign(from(entries), range !== undefined ? { timeRange: range } : {}); + const run = async ( + entries: CaptureDataEntry[], + range: { start: number; end: number } | undefined, + over: Partial = {}, + ) => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const bundle = await assembleBundleStream(request, withRange(entries, range), context(over)); + return members(await bundle.body.bytes()); + }; + const manifestOf = (files: Record) => + JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + time: { start: number; end: number }; + files: Array<{ filename: string }>; + }; + const NOW = 1_700_000_000_000; + + it('takes manifest time.start from the plan, not from the earliest record', async () => { + const files = await run([entry('log', NOW - 1000, 'a')], { start: NOW - 5000, end: NOW }); + expect(manifestOf(files).time).toEqual({ start: NOW - 5000, end: NOW }); + }); + + it('keeps time.start at the plan even when no record was captured', async () => { + const files = await run([], { start: NOW - 5000, end: NOW }); + expect(manifestOf(files).time).toEqual({ start: NOW - 5000, end: NOW }); + }); + + it('ends the manifest time at the trigger moment (the clock), not the snapshot range end', async () => { + const files = await run([], { start: NOW - 5000, end: NOW - 10 }); + expect(manifestOf(files).time.end).toBe(NOW); + }); + + it('drops non-replay records older than the plan start and keeps those at or after it', async () => { + const files = await run( + [ + entry('log', NOW - 6000, 'old'), + entry('log', NOW - 5001, 'just-before'), + entry('log', NOW - 5000, 'edge'), + entry('log', NOW - 10, 'new'), + ], + { start: NOW - 5000, end: NOW }, + ); + const logs = JSON.parse(strFromU8(files['logs.json'] as Uint8Array)) as unknown[]; + expect(logs).toEqual(['edge', 'new']); + }); + + it('omits a member (and its manifest entry) whose records were all older than the plan start', async () => { + const files = await run( + [entry('log', NOW - 6000, 'old'), entry('events.user', NOW - 10, 'e')], + { + start: NOW - 5000, + end: NOW, + }, + ); + expect(files['logs.json']).toBeUndefined(); + expect(manifestOf(files).files.map((f) => f.filename)).not.toContain('logs.json'); + }); + + it('does not trim a type that has an injected encoder (the encoder owns the clamp)', async () => { + const seen: unknown[][] = []; + const files = await run( + [entry('replay', NOW - 9000, 'r')], + { start: NOW - 5000, end: NOW }, + { + fileEncoders: { + replay: (payloads) => { + seen.push(payloads); + return new Uint8Array([1]); + }, + }, + }, + ); + expect(seen).toEqual([['r']]); + expect(files['replay.bin']).toBeDefined(); + expect(manifestOf(files).time.start).toBe(NOW - 5000); + }); + + it('falls back to the earliest record when the source states no range', async () => { + const files = await run( + [entry('log', NOW - 7000, 'a'), entry('log', NOW - 10, 'b')], + undefined, + ); + expect(manifestOf(files).time.start).toBe(NOW - 7000); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['a', 'b']); + }); + + it('does not trim the report-time extras, and they do not move a planned start', async () => { + const request = createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + const bundle = await assembleBundleStream( + request, + withRange([], { start: NOW - 5000, end: NOW }), + context(), + new Map([['events.user', [entry('events.user', NOW - 9000, 'x')]]]), + ); + const files = members(await bundle.body.bytes()); + expect(JSON.parse(strFromU8(files['events.user.json'] as Uint8Array))).toEqual(['x']); + expect(manifestOf(files).time.start).toBe(NOW - 5000); + }); + + it('end to end: a real chunk store, through the exporter, bounds the manifest and trims old logs', async () => { + let now = NOW - 20_000; + const clock = { wallNow: () => now, monotonicNow: () => 0 }; + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 5000, + }); + store.add({ + type: 'log', + timestamp: NOW - 6000, + serialized: JSON.stringify({ timestamp: NOW - 6000, data: 'old' }), + }); + store.add({ + type: 'log', + timestamp: NOW - 100, + serialized: JSON.stringify({ timestamp: NOW - 100, data: 'new' }), + }); + now = NOW; + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + createCaptureExporter(store).stream(), + context(), + ); + const files = members(await bundle.body.bytes()); + expect(manifestOf(files).time).toEqual({ start: NOW - 5000, end: NOW }); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['new']); + }); +}); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index 6577cf1f..f7f56bb9 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -31,6 +31,8 @@ import type { Bundle } from './transport'; // the helpers the buffered assembler exports, so the two cannot drift. File types with an injected binary encoder (the // replay) still collect their payloads and encode at the end; a streaming encoder for them is a later slice. +type TimeRange = { readonly start: number; readonly end: number }; + interface StreamMember { readonly file: ZipDeflate; readonly shape: ReturnType; @@ -44,7 +46,7 @@ function jsonItem(value: unknown): string { export async function assembleBundleStream( request: ReportingRequest, - entries: AsyncIterable, + entries: AsyncIterable & { readonly timeRange?: TimeRange }, context: BundleAssemblyContext, extra?: ReadonlyMap, ): Promise { @@ -52,7 +54,7 @@ export async function assembleBundleStream( // stream does), and a generator that was never read would otherwise never run its own cleanup. const iterator = entries[Symbol.asyncIterator](); try { - return await build(request, iterator, context, extra); + return await build(request, iterator, context, extra, entries.timeRange); } catch (error) { try { await iterator.return?.(); @@ -68,6 +70,7 @@ async function build( iterator: AsyncIterator, context: BundleAssemblyContext, extra: ReadonlyMap | undefined, + plan: TimeRange | undefined, ): Promise { const now = context.clock.wallNow(); const requestJson = buildRequestJson(request, context, now); @@ -106,14 +109,24 @@ async function build( addWhole(APP_TOKEN_FILENAME, context.appToken); // The data pass. `order` is the first-seen order of the types (the manifest lists them in it). - let start = now; + // With a plan the report starts where the plan says (the window), whatever the records are; without one, at the earliest + // record. The plan also bounds the ROLLING records: older ones (the store keeps up to a second of slack) are dropped. + let start = plan?.start ?? now; const order: FileType[] = []; const streamed = new Map(); const collected = new Map(); // types with an injected encoder - const take = (entry: CaptureDataEntry): void => { + const take = (entry: CaptureDataEntry, rolling: boolean): void => { const type = entry.type; - if (entry.timestamp < start) { - start = entry.timestamp; + if (plan === undefined) { + if (entry.timestamp < start) { + start = entry.timestamp; + } + } else if ( + rolling && + entry.timestamp < plan.start && + context.fileEncoders?.[entry.type] === undefined + ) { + return; // before the window; types with an encoder (the replay) are clamped by it instead } if (!streamed.has(type) && !collected.has(type)) { order.push(type); @@ -145,11 +158,11 @@ async function build( member.count += 1; }; for await (const entry of { [Symbol.asyncIterator]: () => iterator }) { - take(entry); + take(entry, true); } for (const list of extra?.values() ?? []) { for (const entry of list) { - take(entry); + take(entry, false); } } diff --git a/packages/core/src/capture-exporter.test.ts b/packages/core/src/capture-exporter.test.ts index 23163339..5ac253bf 100644 --- a/packages/core/src/capture-exporter.test.ts +++ b/packages/core/src/capture-exporter.test.ts @@ -136,6 +136,27 @@ describe('createCaptureExporter — stream', () => { }); }); +describe('createCaptureExporter — stream time range', () => { + const storeWithRange = (range: { start: number; end: number } | undefined): CaptureStore => { + const { store } = fakeStore([]); + const inner = store.snapshot(); + return { + ...store, + snapshot: () => ({ ...inner, ...(range !== undefined ? { timeRange: () => range } : {}) }), + }; + }; + + it('carries the snapshot range on the stream', () => { + const stream = createCaptureExporter(storeWithRange({ start: 5, end: 9 })).stream(); + expect(stream.timeRange).toEqual({ start: 5, end: 9 }); + }); + + it('has no range when the snapshot cannot state one', () => { + const stream = createCaptureExporter(storeWithRange(undefined)).stream(); + expect('timeRange' in stream).toBe(false); + }); +}); + describe('createCaptureExporter — drain', () => { it('deserializes all snapshot records grouped by file type, then releases', async () => { const fake = fakeStore([ diff --git a/packages/core/src/capture-exporter.ts b/packages/core/src/capture-exporter.ts index 003202aa..637a2429 100644 --- a/packages/core/src/capture-exporter.ts +++ b/packages/core/src/capture-exporter.ts @@ -3,6 +3,7 @@ import { defaultEntryFactory } from './capture-data-entry'; import type { CaptureDataEntry, CaptureEntryFactory, + CaptureEntryStream, CaptureExporter, CaptureStore, StoredEntry, @@ -27,7 +28,7 @@ export function createCaptureExporter( onError: (error: unknown) => void = (): void => {}, ): CaptureExporter { return { - stream(options?: { owner?: string }): AsyncIterableIterator { + stream(options?: { owner?: string }): CaptureEntryStream { const snapshot = store.snapshot(options); const records = snapshot.stream(); // The snapshot is taken NOW (it freezes the window at the moment of the call), so its release must not depend on the @@ -58,7 +59,9 @@ export function createCaptureExporter( release(); } })(); - const stream: AsyncIterableIterator = { + const range = snapshot.timeRange?.(); + const stream: CaptureEntryStream = { + ...(range !== undefined ? { timeRange: range } : {}), next: () => inner.next(), return: async (value) => { try { diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 2db72200..3f41caaa 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -306,3 +306,138 @@ describe('flush', () => { expect(store.flush).toBeUndefined(); }); }); + +describe('createChunkCaptureStore — snapshot time range', () => { + const mutableClock = (start: number): { clock: Clock; set: (now: number) => void } => { + let now = start; + return { clock: { wallNow: () => now, monotonicNow: () => 0 }, set: (v) => (now = v) }; + }; + + it('reports the window ending now when the store has been recording for longer than the window', () => { + const { clock, set } = mutableClock(1000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 5000, + }); + set(20_000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 15_000, end: 20_000 }); + }); + + it('starts at the store epoch (first part start) while the store is younger than the window', () => { + const { clock, set } = mutableClock(1000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 60_000, + }); + set(4000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 1000, end: 4000 }); + }); + + it('restarts the epoch on clear()', () => { + const { clock, set } = mutableClock(1000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 60_000, + }); + set(9000); + store.clear(); + set(10_000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 9000, end: 10_000 }); + }); + + it('never reports start after end when the wall clock steps backwards', () => { + const { clock, set } = mutableClock(5000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { clock }); + set(2000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 2000, end: 2000 }); + }); + + it('fixes the range at snapshot time', () => { + const { clock, set } = mutableClock(1000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 5000, + }); + set(20_000); + const snapshot = store.snapshot(); + set(30_000); + expect(snapshot.timeRange?.()).toEqual({ start: 15_000, end: 20_000 }); + }); + + it('keeps the wrapped snapshot working: stream, drainAll, release', async () => { + const { clock } = mutableClock(1000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { clock }); + store.add({ type: 'log', timestamp: 1, serialized: 'a' }); + const snapshot = store.snapshot(); + const streamed: StoredEntry[] = []; + for await (const record of snapshot.stream()) { + streamed.push(record); + } + expect(streamed.map((r) => r.serialized)).toEqual(['a']); + expect((await snapshot.drainAll()).get('log')?.length).toBe(1); + snapshot.release(); + expect(await snapshot.drainAll()).toEqual(new Map()); + }); + + it('forwards unpin and inMemory only when the backend snapshot has them', () => { + const calls: string[] = []; + const base = createMemoryChunkBackend(); + const backend = { + ...base, + snapshot: (frozen: Parameters[0]) => ({ + ...base.snapshot(frozen), + unpin: () => calls.push('unpin'), + inMemory: () => new Map([['log' as FileType, []]]), + }), + }; + const store = createChunkCaptureStore(backend, { clock: clockAt(0) }); + const snapshot = store.snapshot(); + snapshot.unpin?.(); + expect(calls).toEqual(['unpin']); + expect(snapshot.inMemory?.()).toEqual(new Map([['log', []]])); + const plain = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + }).snapshot(); + expect(plain.unpin).toBeUndefined(); + expect(plain.inMemory).toBeUndefined(); + }); + + it('delegates to a snapshot whose methods depend on `this`', async () => { + class ThisSnapshot { + private readonly items = [{ type: 'log' as FileType, timestamp: 1, serialized: 'a' }]; + released = false; + async *stream(): AsyncIterableIterator { + yield* this.items; + } + async drainAll(): Promise> { + return new Map([['log', [...this.items]]]); + } + release(): void { + this.released = true; + } + unpin(): void { + this.released = true; + } + inMemory(): Map { + return new Map([['log', [...this.items]]]); + } + } + const inner = new ThisSnapshot(); + const base = createMemoryChunkBackend(); + const store = createChunkCaptureStore( + { ...base, snapshot: () => inner as never }, + { clock: clockAt(0) }, + ); + const snapshot = store.snapshot(); + const seen: string[] = []; + for await (const r of snapshot.stream()) seen.push(r.serialized); + expect(seen).toEqual(['a']); + expect((await snapshot.drainAll()).get('log')?.length).toBe(1); + expect(snapshot.inMemory?.().get('log')?.length).toBe(1); + snapshot.unpin?.(); + expect(inner.released).toBe(true); + inner.released = false; + snapshot.release(); + expect(inner.released).toBe(true); + }); +}); diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index e413525d..8ffe03ce 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -60,6 +60,23 @@ type Lane = 'data' | 'visual'; // byte cap can shed them (whole base segments) before it touches logs, network or events. const VISUAL_TYPES: ReadonlySet = new Set(['replay', 'video']); +// The backend's snapshot plus the range fixed at snapshot time. Explicit delegation (not a spread) keeps any `this`-bound +// snapshot working, and `unpin` / `inMemory` stay absent when the backend's snapshot has none (callers test for them). +function withTimeRange( + snapshot: CaptureSnapshot, + range: { readonly start: number; readonly end: number }, +): CaptureSnapshot { + const { unpin, inMemory } = snapshot; + return { + stream: () => snapshot.stream(), + drainAll: () => snapshot.drainAll(), + release: () => snapshot.release(), + ...(unpin !== undefined ? { unpin: () => unpin.call(snapshot) } : {}), + ...(inMemory !== undefined ? { inMemory: () => inMemory.call(snapshot) } : {}), + timeRange: () => range, + }; +} + export function createChunkCaptureStore( durableBackend: ChunkBackend, options?: ChunkCaptureStoreOptions, @@ -110,7 +127,10 @@ export function createChunkCaptureStore( return part; }; - let dataPart = openNewPart(clock.wallNow(), 'data'); + const firstStart = clock.wallNow(); + let dataPart = openNewPart(firstStart, 'data'); + // When this store started recording (first part start since launch / the last clear): nothing older can exist. + let epoch = firstStart; const partFor = (lane: Lane): IndexedPart => { if (lane === 'data') { @@ -253,7 +273,9 @@ export function createChunkCaptureStore( // Freeze each in-window part with its CURRENT entry count — the boundary that keeps post-snapshot // captures (to the still-open part) out of the frozen view. The backend isolates the read. const frozen: FrozenPart[] = parts.map((part) => ({ ref: part.ref, count: part.count })); - return backend.snapshot(frozen); + const end = clock.wallNow(); + const range = { start: Math.min(Math.max(end - maxRecordingTimeMs, epoch), end), end }; + return withTimeRange(backend.snapshot(frozen), range); }, clear(): void { @@ -261,7 +283,8 @@ export function createChunkCaptureStore( parts = []; totalBytes = 0; visualPart = undefined; - dataPart = openNewPart(clock.wallNow(), 'data'); + epoch = clock.wallNow(); + dataPart = openNewPart(epoch, 'data'); }, // Forwarded only when the backend HAS one, so `'flush' in store` stays an honest test for "this store diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 65d7053a..470bf467 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -153,6 +153,13 @@ export interface CaptureSnapshot { * write was refused. A reader that finds the medium unreadable (or hung) serves this instead of nothing. */ inMemory?(): Map; + /** + * Optional: the time range this snapshot is meant to cover, fixed when it was taken (Android's `timeRange`): the + * recording window ending now, clipped to when the store started recording. It comes from the store's own window and + * part index, never from the records, so an export can state the report's start without a pre-scan. Absent when the + * store cannot say (the exporter then falls back to the earliest record). + */ + timeRange?(): { readonly start: number; readonly end: number }; } /** @@ -175,10 +182,15 @@ export interface CaptureAggregator { * returns them streaming (one-by-one) or all-at-once (grouped by file type), then releases the * snapshot — the live store keeps rolling throughout (no drain-on-read). */ +/** The exporter's entry stream, plus the time range of the snapshot it reads (see {@link CaptureSnapshot.timeRange}). */ +export interface CaptureEntryStream extends AsyncIterableIterator { + readonly timeRange?: { readonly start: number; readonly end: number }; +} + export interface CaptureExporter { /** Snapshot the store, stream deserialized entries one-by-one, then release the snapshot. * `options.owner` scopes the export to one tenant (see CaptureStore.snapshot). */ - stream(options?: { owner?: string }): AsyncIterableIterator; + stream(options?: { owner?: string }): CaptureEntryStream; /** Snapshot the store, read + deserialize all entries grouped by file type, then release. * `options.owner` scopes the export to one tenant (see CaptureStore.snapshot). */ drain(options?: { owner?: string }): Promise>; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e641a3e..3068f8f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -134,6 +134,7 @@ export type { CaptureAggregator, CaptureDataEntry, CaptureEntryFactory, + CaptureEntryStream, CaptureExporter, CaptureProvider, CaptureProviderInit, diff --git a/packages/core/src/partitioned-capture-store.test.ts b/packages/core/src/partitioned-capture-store.test.ts index 0ba2a6a3..b9808225 100644 --- a/packages/core/src/partitioned-capture-store.test.ts +++ b/packages/core/src/partitioned-capture-store.test.ts @@ -347,3 +347,52 @@ describe('the default error sink', () => { expect(() => store.add(rec('a', '1'))).not.toThrow(); }); }); + +describe('createPartitionedCaptureStore — snapshot time range', () => { + const rangedPartition = (range?: { start: number; end: number }): CaptureStore => { + const base = fakePartition(); + return { + ...base, + snapshot: () => ({ + ...base.snapshot(), + ...(range !== undefined ? { timeRange: () => range } : {}), + }), + }; + }; + + it('is the partition range for a single partition', () => { + const store = createPartitionedCaptureStore({ + createPartition: () => rangedPartition({ start: 10, end: 20 }), + }); + store.add(rec('tenant-A', 'x')); + expect(store.snapshot({ owner: 'tenant-A' }).timeRange?.()).toEqual({ start: 10, end: 20 }); + }); + + it('is absent when the partition cannot say', () => { + const store = createPartitionedCaptureStore({ createPartition: () => rangedPartition() }); + store.add(rec('tenant-A', 'x')); + expect(store.snapshot({ owner: 'tenant-A' }).timeRange).toBeUndefined(); + }); + + it('is absent when no partition exists', () => { + const store = createPartitionedCaptureStore({ + createPartition: () => rangedPartition({ start: 1, end: 2 }), + }); + expect(store.snapshot({ owner: 'nobody' }).timeRange).toBeUndefined(); + }); + + it('answers per scope: the default partition, a tenant, and absent for a partition that cannot say', () => { + const ranges = [{ start: 10, end: 20 }, { start: 5, end: 15 }, undefined]; + let i = 0; + const store = createPartitionedCaptureStore({ + createPartition: () => rangedPartition(ranges[i++]), + }); + store.add(rec(undefined, 'a')); + store.add(rec('t1', 'b')); + store.add(rec('t2', 'c')); + // An unscoped snapshot reads only the default partition; reach several sources through the owners instead. + expect(store.snapshot().timeRange?.()).toEqual({ start: 10, end: 20 }); + expect(store.snapshot({ owner: 't1' }).timeRange?.()).toEqual({ start: 5, end: 15 }); + expect(store.snapshot({ owner: 't2' }).timeRange).toBeUndefined(); + }); +}); diff --git a/packages/core/src/partitioned-capture-store.ts b/packages/core/src/partitioned-capture-store.ts index 77706f72..632e11b0 100644 --- a/packages/core/src/partitioned-capture-store.ts +++ b/packages/core/src/partitioned-capture-store.ts @@ -157,7 +157,17 @@ export function createPartitionedCaptureStore( return partition !== undefined ? [partition] : []; }; + // A snapshot reads at most one partition (default or one tenant), so its range is that partition's; with none there is + // nothing to state a range for. + const combinedRange = ( + sources: CaptureSnapshot[], + ): { timeRange: () => { start: number; end: number } } | undefined => { + const range = sources[0]?.timeRange?.(); + return range !== undefined ? { timeRange: () => range } : undefined; + }; + const combinedSnapshot = (sources: CaptureSnapshot[]): CaptureSnapshot => ({ + ...combinedRange(sources), stream: async function* () { for (const source of sources) { yield* source.stream(); From e70d56045108948f58c9f2e62c64b8191e455c2a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sat, 31 Oct 2026 23:32:40 +0500 Subject: [PATCH 07/33] feat(replay): streaming replay encoder with orphan trim and window clamp (streaming export S4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileStreamEncoder contract in core, streamEncoders in the streaming assembler (lazy member, write errors skip the record, zip errors fail the bundle), and createReplayStreamEncoder in @bugsee/replay: streaming gzip, drops events before the first Meta+FullSnapshot pair, clamps pre-window events to the plan start, writes nothing without a base. 🤖 Generated with [Claude Code](https://claude.ai/code) --- docs/design/streaming-export.md | 2 +- packages/core/src/bundle-assembler.ts | 6 + .../core/src/bundle-stream-assembler.test.ts | 272 +++++++++++++++++- packages/core/src/bundle-stream-assembler.ts | 55 +++- packages/core/src/file-stream-encoder.ts | 28 ++ packages/core/src/index.ts | 1 + packages/replay/src/index.ts | 2 + packages/replay/src/register.test.ts | 20 +- packages/replay/src/register.ts | 13 +- packages/replay/src/stream-encoder.test.ts | 223 ++++++++++++++ packages/replay/src/stream-encoder.ts | 100 +++++++ 11 files changed, 714 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/file-stream-encoder.ts create mode 100644 packages/replay/src/stream-encoder.test.ts create mode 100644 packages/replay/src/stream-encoder.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 6ca37291..1293b6b0 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -214,7 +214,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S1** | **DONE 2026-10-31** (two review rounds). Inventory every reader/writer of `Bundle.body`, `capturedByType` mutation and `BundleStore`; add `BundleBody` + memory sink + `asBody()`; `Bundle.body: Uint8Array \| BundleBody`; no behaviour change | contract + type tests | | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | -| **S4** | Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | +| **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | diff --git a/packages/core/src/bundle-assembler.ts b/packages/core/src/bundle-assembler.ts index ea850247..7e6b0c53 100644 --- a/packages/core/src/bundle-assembler.ts +++ b/packages/core/src/bundle-assembler.ts @@ -20,6 +20,7 @@ import { type BundleFile, writeBundleZip } from './bundle-writer'; import type { Clock } from './clock'; import type { CaptureDataEntry } from './contracts'; import { stampCrashProvenance } from './crash'; +import type { FileStreamEncoder } from './file-stream-encoder'; import type { ReportingRequest } from './reporting'; import type { RequestContext } from './request-context'; import type { Bundle } from './transport'; @@ -58,6 +59,11 @@ export interface BundleAssemblyContext { * JSON path — keeping core codec-free. The encoder receives the type's ordered payloads (`entry.data`). */ fileEncoders?: Partial Uint8Array>>; + /** + * Per-file-type STREAMING encoders (the replay's `replay.bin`): honoured by the streaming assembler only, and preferred + * over `fileEncoders` for the same type. See {@link FileStreamEncoder}. + */ + streamEncoders?: Partial>; /** Sink for non-fatal problems (a dropped user attachment). Absent → they are dropped silently. */ onError?: (error: unknown) => void; } diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 9b92ee80..2c3e0419 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -1,5 +1,5 @@ import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; -import { strFromU8, unzipSync } from '@bugsee/util'; +import { strFromU8, strToU8, unzipSync } from '@bugsee/util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; import { assembleBundleStream } from './bundle-stream-assembler'; @@ -524,4 +524,274 @@ describe('assembleBundleStream — time plan', () => { expect(manifestOf(files).time).toEqual({ start: NOW - 5000, end: NOW }); expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['new']); }); + + describe('stream encoders', () => { + const NOW2 = 1_700_000_000_000; + // A recording encoder: emits one chunk per entry, a trailer, and can declare itself empty. + const recorder = (opts: { silent?: boolean; failOn?: unknown } = {}) => { + const log: string[] = []; + const plans: unknown[] = []; + const encoder = { + begin: (plan: unknown) => { + plans.push(plan); + return { + write: (e: CaptureDataEntry) => { + if (e.data === opts.failOn) throw new Error('bad record'); + log.push(`w:${String(e.data)}`); + return opts.silent === true ? [] : [strToU8(`<${String(e.data)}>`)]; + }, + end: () => { + log.push('end'); + return opts.silent === true ? [] : [strToU8('|end')]; + }, + }; + }, + }; + return { encoder, log, plans }; + }; + const runEnc = async ( + entries: CaptureDataEntry[], + range: { start: number; end: number } | undefined, + over: Partial, + ) => { + const src = Object.assign(from(entries), range !== undefined ? { timeRange: range } : {}); + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + src, + context(over), + ); + return members(await bundle.body.bytes()); + }; + const mf = (files: Record) => + JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + time: { start: number; end: number }; + files: Array<{ filename: string; type: string }>; + }; + + it('feeds entries in order, writes the chunks into the member and lists it in the manifest', async () => { + const { encoder, log, plans } = recorder(); + const files = await runEnc( + [ + entry('replay', NOW2 - 9000, 'a'), + entry('log', NOW2 - 10, 'l'), + entry('replay', NOW2 - 8000, 'b'), + ], + { start: NOW2 - 5000, end: NOW2 }, + { streamEncoders: { replay: encoder } }, + ); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(log).toEqual(['w:a', 'w:b', 'end']); + expect(plans).toEqual([{ start: NOW2 - 5000, end: NOW2 }]); + expect(mf(files).files.map((f) => f.filename)).toContain('replay.bin'); + }); + + it('begins the session with an absent start when the source states no range', async () => { + const { encoder, plans } = recorder(); + await runEnc([entry('replay', NOW2 - 100, 'a')], undefined, { + streamEncoders: { replay: encoder }, + }); + expect(plans).toEqual([{ start: undefined, end: NOW2 }]); + }); + + it('omits the member and its manifest entry when the encoder produced no bytes', async () => { + const { encoder } = recorder({ silent: true }); + const files = await runEnc([entry('replay', NOW2 - 100, 'a')], undefined, { + streamEncoders: { replay: encoder }, + }); + expect(files['replay.bin']).toBeUndefined(); + expect(mf(files).files.map((f) => f.filename)).not.toContain('replay.bin'); + }); + + it('skips a record whose write throws, reports it, and keeps the rest', async () => { + const { encoder } = recorder({ failOn: 'bad' }); + const errors: unknown[] = []; + const files = await runEnc( + [ + entry('replay', NOW2 - 300, 'a'), + entry('replay', NOW2 - 200, 'bad'), + entry('replay', NOW2 - 100, 'b'), + ], + undefined, + { streamEncoders: { replay: encoder }, onError: (e) => errors.push(e) }, + ); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(errors).toHaveLength(1); + }); + + it('prefers the stream encoder over a buffered one for the same type', async () => { + const { encoder } = recorder(); + const files = await runEnc([entry('replay', NOW2 - 100, 'a')], undefined, { + streamEncoders: { replay: encoder }, + fileEncoders: { replay: () => new Uint8Array([9]) }, + }); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + }); + + it('does not trim a streamed type by the plan start (the encoder clamps)', async () => { + const { encoder, log } = recorder(); + await runEnc( + [entry('replay', NOW2 - 9000, 'old')], + { start: NOW2 - 5000, end: NOW2 }, + { + streamEncoders: { replay: encoder }, + }, + ); + expect(log).toEqual(['w:old', 'end']); + }); + + it('closes cleanly when the stream encoder never saw an entry (no session, no member)', async () => { + const { encoder, plans } = recorder(); + const files = await runEnc([], undefined, { streamEncoders: { replay: encoder } }); + expect(plans).toEqual([]); + expect(files['replay.bin']).toBeUndefined(); + }); + + it('creates the member in end() when every chunk only appears there (buffering encoders)', async () => { + const encoder = { + begin: () => ({ + write: () => [], + end: () => [strToU8('all-at-end')], + }), + }; + const files = await runEnc([entry('replay', NOW2 - 100, 'a')], undefined, { + streamEncoders: { replay: encoder }, + }); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('all-at-end'); + expect(mf(files).files.map((f) => f.filename)).toContain('replay.bin'); + }); + + it('fails the bundle and closes the source when end() throws', async () => { + const encoder = { + begin: () => ({ + write: () => [strToU8('x')], + end: (): Uint8Array[] => { + throw new Error('end failed'); + }, + }), + }; + let closed = false; + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => { + const it = from([entry('replay', NOW2 - 100, 'a')])[Symbol.asyncIterator](); + return { + next: () => it.next(), + return: async () => { + closed = true; + return { done: true as const, value: undefined }; + }, + }; + }, + }; + await expect( + assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + source, + context({ streamEncoders: { replay: encoder } }), + ), + ).rejects.toThrow('end failed'); + expect(closed).toBe(true); + }); + + it('fails the bundle (not a skipped record) when the member name clashes with another member', async () => { + const { encoder } = recorder(); + const errors: unknown[] = []; + const crash: CrashJson = { + exception_type: 'error', + ndkCrash: false, + handled: true, + exception: { name: 'E', reason: 'r', frames: [] }, + }; + await expect( + assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r2', crash }), + from([entry('crash', NOW2 - 100, 'a')]), + context({ streamEncoders: { crash: encoder }, onError: (e) => errors.push(e) }), + ), + ).rejects.toThrow(/duplicate file name: crash.json/); + expect(errors).toEqual([]); + }); + + it('handles several streamed types and a mix with plain and buffered types, in first-seen order', async () => { + const a = recorder(); + const b = recorder(); + const files = await runEnc( + [ + entry('log', NOW2 - 300, 'l1'), + entry('replay', NOW2 - 250, 'r1'), + entry('viewtree', NOW2 - 200, 'v1'), + entry('replay', NOW2 - 150, 'r2'), + entry('events.user', NOW2 - 100, 'e1'), + ], + undefined, + { + streamEncoders: { replay: a.encoder, viewtree: b.encoder }, + fileEncoders: { 'events.user': () => strToU8('buffered') }, + }, + ); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(strFromU8(files['viewtree.json'] as Uint8Array)).toBe('|end'); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['l1']); + expect(strFromU8(files['events.user.json'] as Uint8Array)).toBe('buffered'); + expect(mf(files).files.map((f) => f.type)).toEqual([ + 'log', + 'replay', + 'viewtree', + 'events.user', + ]); + }); + + it('feeds report-time extras of a streamed type through the encoder too', async () => { + const { encoder, log } = recorder(); + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + from([entry('replay', NOW2 - 100, 'a')]), + context({ streamEncoders: { replay: encoder } }), + new Map([['replay', [entry('replay', NOW2 - 50, 'x')]]]), + ); + const files = members(await bundle.body.bytes()); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(log).toEqual(['w:a', 'w:x', 'end']); + }); + + it('closes the source and rethrows the original error when it fails after a member was opened', async () => { + const { encoder } = recorder(); + let closed = false; + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => { + let n = 0; + return { + next: async () => { + if (n++ === 0) + return { done: false as const, value: entry('replay', NOW2 - 100, 'a') }; + throw new Error('source died'); + }, + return: async () => { + closed = true; + return { done: true as const, value: undefined }; + }, + }; + }, + }; + await expect( + assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + source, + context({ streamEncoders: { replay: encoder } }), + ), + ).rejects.toThrow('source died'); + expect(closed).toBe(true); + }); + + it('passes the clock moment, not the snapshot range end, as plan.end', async () => { + const { encoder, plans } = recorder(); + await runEnc( + [entry('replay', NOW2 - 100, 'a')], + { start: NOW2 - 5000, end: NOW2 - 77 }, + { + streamEncoders: { replay: encoder }, + }, + ); + expect(plans).toEqual([{ start: NOW2 - 5000, end: NOW2 }]); + }); + }); }); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index f7f56bb9..93a91ae6 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -18,6 +18,7 @@ import { } from './bundle-assembler'; import { type BundleBody, createMemoryBody } from './bundle-body'; import type { CaptureDataEntry } from './contracts'; +import type { StreamEncoderSession } from './file-stream-encoder'; import type { ReportingRequest } from './reporting'; import type { Bundle } from './transport'; @@ -33,6 +34,12 @@ import type { Bundle } from './transport'; type TimeRange = { readonly start: number; readonly end: number }; +interface EncodedMember { + readonly session: StreamEncoderSession; + /** Created with the first chunk, so a type that produced nothing leaves no empty member behind. */ + file: ZipDeflate | undefined; +} + interface StreamMember { readonly file: ZipDeflate; readonly shape: ReturnType; @@ -115,6 +122,16 @@ async function build( const order: FileType[] = []; const streamed = new Map(); const collected = new Map(); // types with an injected encoder + const encoded = new Map(); // types with a stream encoder + const writeChunks = (type: FileType, member: EncodedMember, chunks: Uint8Array[]): void => { + for (const chunk of chunks) { + if (member.file === undefined) { + member.file = new ZipDeflate(claim(fileNameForType(type))); + zip.add(member.file); + } + member.file.push(chunk, false); + } + }; const take = (entry: CaptureDataEntry, rolling: boolean): void => { const type = entry.type; if (plan === undefined) { @@ -124,13 +141,20 @@ async function build( } else if ( rolling && entry.timestamp < plan.start && - context.fileEncoders?.[entry.type] === undefined + context.fileEncoders?.[entry.type] === undefined && + context.streamEncoders?.[entry.type] === undefined ) { return; // before the window; types with an encoder (the replay) are clamped by it instead } - if (!streamed.has(type) && !collected.has(type)) { + if (!streamed.has(type) && !collected.has(type) && !encoded.has(type)) { order.push(type); - if (context.fileEncoders?.[type] !== undefined) { + const streamEncoder = context.streamEncoders?.[type]; + if (streamEncoder !== undefined) { + encoded.set(type, { + session: streamEncoder.begin({ start: plan?.start, end: now }), + file: undefined, + }); + } else if (context.fileEncoders?.[type] !== undefined) { collected.set(type, []); } else { const file = new ZipDeflate(claim(fileNameForType(type))); @@ -138,6 +162,19 @@ async function build( streamed.set(type, { file, shape: fileShape(type), count: 0 }); } } + const session = encoded.get(type); + if (session !== undefined) { + let chunks: Uint8Array[]; + try { + chunks = session.session.write(entry); + } catch (error) { + context.onError?.(error); // one bad record never costs the member + return; + } + // Outside the try: a name clash (or any zip failure) is not a bad record and must fail the bundle, as it does everywhere. + writeChunks(type, session, chunks); + return; + } const payloads = collected.get(type); if (payloads !== undefined) { payloads.push(entry.data); @@ -169,10 +206,20 @@ async function build( const files: ManifestFileEntry[] = []; const dataNames: string[] = []; for (const type of order) { + const stream = encoded.get(type); + if (stream !== undefined) { + writeChunks(type, stream, stream.session.end()); + if (stream.file === undefined) { + continue; // nothing was produced: no member, no manifest entry + } + stream.file.push(new Uint8Array(0), true); + } files.push(manifestEntryForType(type)); dataNames.push(fileNameForType(type)); const member = streamed.get(type); - if (member !== undefined) { + if (stream !== undefined) { + // finalized above + } else if (member !== undefined) { member.file.push(strToU8(member.shape.close), true); } else { const encode = (context.fileEncoders as NonNullable)[type] as ( diff --git a/packages/core/src/file-stream-encoder.ts b/packages/core/src/file-stream-encoder.ts new file mode 100644 index 00000000..96fe101f --- /dev/null +++ b/packages/core/src/file-stream-encoder.ts @@ -0,0 +1,28 @@ +import type { CaptureDataEntry } from './contracts'; + +// A STREAMING per-type binary encoder (the replay's `replay.bin`): the streaming assembler feeds it the type's entries one +// at a time and writes whatever bytes it returns into the bundle member straight away, so the type's whole payload list is +// never held. It is the streaming sibling of `fileEncoders` (which takes every payload at once and stays for encoders that +// cannot stream); a type with both uses this one. Core stays codec-free: the platform/extension provides the encoder. + +/** The export's time plan: what the report is meant to cover. `start` is absent when the store could not say. */ +export interface ExportPlan { + readonly start: number | undefined; + readonly end: number; +} + +/** One export of one type. */ +export interface StreamEncoderSession { + /** Take the next entry (oldest first). Returns zero or more output chunks. May throw: that entry is skipped and reported. */ + write(entry: CaptureDataEntry): Uint8Array[]; + /** + * No more entries: the trailing chunks. A session that returned no chunk at all (from `write` or `end`) produced nothing + * usable, and the member and its manifest entry are omitted: an encoder with nothing to say must stay silent from the start. + * A throwing `end()` fails the whole bundle (a half-written member cannot be taken back). + */ + end(): Uint8Array[]; +} + +export interface FileStreamEncoder { + begin(plan: ExportPlan): StreamEncoderSession; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3068f8f0..1807bd7e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -213,6 +213,7 @@ export { InputTool } from './events'; export { createExtensionRegistry, type ExtensionRegistry } from './extension-registry'; export { createFileCaptureStore, type FileCaptureStoreOptions } from './file-capture-store'; export { createFileChunkBackend, type FileChunkBackendOptions } from './file-chunk-backend'; +export type { ExportPlan, FileStreamEncoder, StreamEncoderSession } from './file-stream-encoder'; export { type BreadcrumbFilter, createFilterStore, diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index ae3f8541..7e4cba5e 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -19,5 +19,7 @@ export { type RegisterReplayOptions, type ReplayClientLike, type ReplayFileEncoders, + type ReplayStreamEncoders, registerReplay, } from './register'; +export { createReplayStreamEncoder } from './stream-encoder'; diff --git a/packages/replay/src/register.test.ts b/packages/replay/src/register.test.ts index 0b3ac29b..3ba7a574 100644 --- a/packages/replay/src/register.test.ts +++ b/packages/replay/src/register.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom -- selector validation needs a DOM to parse against import { type CaptureDataEntry, + CaptureDataEntryBase, type CaptureProvider, type CaptureProviderInit, createBlackoutState, @@ -9,7 +10,7 @@ import type { eventWithTime, recordOptions } from '@bugsee/rrweb'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { encodeReplay } from './encoder'; import type { ReplayRecorder } from './recorder'; -import { type ReplayFileEncoders, registerReplay } from './register'; +import { type ReplayFileEncoders, type ReplayStreamEncoders, registerReplay } from './register'; function fakeClient() { const providers: CaptureProvider[] = []; @@ -45,6 +46,23 @@ describe('registerReplay', () => { vi.unstubAllGlobals(); }); + it('registers the streaming replay encoder into the stream map when one is given, and not otherwise', () => { + const streamEncoders: ReplayStreamEncoders = {}; + mustRegister(fakeClient(), {}, { record: fakeRecord().record, streamEncoders }); + expect(typeof streamEncoders.replay?.begin).toBe('function'); + // It is the real encoder: a base in, a gzip out; and nothing is written when the map is not given. + const session = streamEncoders.replay?.begin({ start: undefined, end: 1 }); + const chunks = [ + ...(session?.write(new CaptureDataEntryBase('replay', 1, { type: 4, timestamp: 1 })) ?? []), + ...(session?.write(new CaptureDataEntryBase('replay', 1, { type: 2, timestamp: 1 })) ?? []), + ...(session?.end() ?? []), + ]; + expect(chunks[0]?.[0]).toBe(0x1f); + const fileEncoders: ReplayFileEncoders = {}; + mustRegister(fakeClient(), fileEncoders, { record: fakeRecord().record }); + expect(fileEncoders.replay).toBe(encodeReplay); + }); + it('installs the recorder capture-provider on the client + registers the replay.bin encoder', () => { const client = fakeClient(); const fileEncoders: ReplayFileEncoders = {}; diff --git a/packages/replay/src/register.ts b/packages/replay/src/register.ts index e43218b6..d2548f26 100644 --- a/packages/replay/src/register.ts +++ b/packages/replay/src/register.ts @@ -4,7 +4,7 @@ // `fileEncoders` map (read at each report's bundle assembly). The browser launch lazy-`import()`s this // module + calls `registerReplay` when the `replay` option is truthy. Returns the recorder so the // caller can wire `client.startBlackout` → its blackout controls (or pass `blackout`, and the recorder follows it). -import type { BlackoutGate, CaptureProvider } from '@bugsee/core'; +import type { BlackoutGate, CaptureProvider, FileStreamEncoder } from '@bugsee/core'; import { record as rrwebRecord } from '@bugsee/rrweb'; import { encodeReplay } from './encoder'; import { type ReplayMaskingOptions, resolveReplayMaskingOptions } from './masking'; @@ -14,6 +14,7 @@ import { type ReplayRecorder, type ReplayRecordFn, } from './recorder'; +import { createReplayStreamEncoder } from './stream-encoder'; /** The client surface `registerReplay` needs (structural — no hard @bugsee/core client dep). */ export interface ReplayClientLike { @@ -25,6 +26,11 @@ export interface ReplayFileEncoders { replay?: (payloads: unknown[]) => Uint8Array; } +/** The shared `streamEncoders` map `registerReplay` writes its streaming `replay` encoder into (when given one). */ +export interface ReplayStreamEncoders { + replay?: FileStreamEncoder; +} + export interface RegisterReplayOptions extends ReplayMaskingOptions { /** Full-snapshot cadence (ms) — bounds the retained ring window. Default 60000. */ checkoutEveryNms?: number; @@ -35,6 +41,8 @@ export interface RegisterReplayOptions extends ReplayMaskingOptions { /** Internal-error sink. Today: an invalid masking selector that was dropped and escalated — * without it the downgrade is silent, which is precisely how the original fail-open survived. */ onError?: (error: unknown) => void; + /** The client's shared stream-encoder map: when given, the streaming `replay.bin` encoder is registered into it. */ + streamEncoders?: ReplayStreamEncoders; /** The client's blackout gate (`client.getService(BlackoutToken)`): the recorder pauses/resumes with it. */ blackout?: BlackoutGate; } @@ -83,5 +91,8 @@ export function registerReplay( // Register the encoder into the SHARED map the client reads at assembly time — so the next report's // `replay` entries serialize to the gzipped `replay.bin`. fileEncoders.replay = encodeReplay; + if (options.streamEncoders !== undefined) { + options.streamEncoders.replay = createReplayStreamEncoder(); + } return provider; } diff --git a/packages/replay/src/stream-encoder.test.ts b/packages/replay/src/stream-encoder.test.ts new file mode 100644 index 00000000..15e005f6 --- /dev/null +++ b/packages/replay/src/stream-encoder.test.ts @@ -0,0 +1,223 @@ +import { CaptureDataEntryBase } from '@bugsee/core'; +import { gunzipSync, strFromU8 } from 'fflate'; +import { describe, expect, it } from 'vitest'; +import { encodeReplay } from './encoder'; +import { createReplayStreamEncoder } from './stream-encoder'; + +const META = 4; +const FULL = 2; +const INC = 3; +const ev = (type: number, timestamp: number, extra: Record = {}) => ({ + type, + timestamp, + data: { n: `${type}@${timestamp}`, ...extra }, +}); + +function encode( + events: unknown[], + plan: { start: number | undefined; end: number } = { start: undefined, end: 10_000 }, +) { + const session = createReplayStreamEncoder().begin(plan); + const chunks: Uint8Array[] = []; + for (const e of events) { + chunks.push( + ...session.write( + new CaptureDataEntryBase('replay', (e as { timestamp?: number } | null)?.timestamp ?? 0, e), + ), + ); + } + chunks.push(...session.end()); + const total = chunks.reduce((n, c) => n + c.length, 0); + const bytes = new Uint8Array(total); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.length; + } + return { bytes, produced: total > 0 }; +} +const decode = (bytes: Uint8Array): Array<{ type: number; timestamp: number }> => + JSON.parse(strFromU8(gunzipSync(bytes))); +const shape = (events: Array<{ type: number; timestamp: number }>) => + events.map((e) => `${e.type}@${e.timestamp}`); + +describe('createReplayStreamEncoder', () => { + it('writes a gzip of the JSON event array that decodes to exactly what the one-shot encoder encodes for a clean stream', () => { + const events = [ev(META, 100), ev(FULL, 100), ev(INC, 116), ev(INC, 132)]; + const { bytes } = encode(events); + expect(bytes[0]).toBe(0x1f); + expect(bytes[1]).toBe(0x8b); + expect(decode(bytes)).toEqual(events); + expect(decode(bytes)).toEqual(decode(encodeReplay(events))); + }); + + it('drops events before the first Meta+FullSnapshot pair (orphans of an evicted base)', () => { + const { bytes } = encode([ev(INC, 10), ev(INC, 20), ev(META, 30), ev(FULL, 30), ev(INC, 40)]); + expect(shape(decode(bytes))).toEqual(['4@30', '2@30', '3@40']); + }); + + it('keeps the LAST Meta before the base when several precede it', () => { + const { bytes } = encode([ev(META, 1), ev(META, 2), ev(FULL, 2), ev(INC, 3)]); + expect(shape(decode(bytes))).toEqual(['4@2', '2@2', '3@3']); + }); + + it('drops a FullSnapshot that has no Meta directly before it, and a Meta with another event after it', () => { + const { bytes } = encode([ + ev(FULL, 1), + ev(META, 2), + ev(INC, 3), + ev(FULL, 4), + ev(META, 5), + ev(FULL, 5), + ev(INC, 6), + ]); + expect(shape(decode(bytes))).toEqual(['4@5', '2@5', '3@6']); + }); + + it('keeps later Meta/FullSnapshot pairs (checkouts) and everything after the base', () => { + const events = [ev(META, 1), ev(FULL, 1), ev(INC, 2), ev(META, 3), ev(FULL, 3), ev(INC, 4)]; + expect(decode(encode(events).bytes)).toEqual(events); + }); + + it('produces no bytes at all when no base exists', () => { + const { produced } = encode([ev(INC, 1), ev(FULL, 2), ev(META, 3)]); + expect(produced).toBe(false); + }); + + it('produces no bytes for no events', () => { + expect(encode([]).produced).toBe(false); + }); + + it('clamps events older than the plan start to the plan start, in order, leaving later ones alone', () => { + const { bytes } = encode( + [ev(META, 100), ev(FULL, 100), ev(INC, 150), ev(INC, 200), ev(INC, 300)], + { start: 200, end: 1000 }, + ); + expect(shape(decode(bytes))).toEqual(['4@200', '2@200', '3@200', '3@200', '3@300']); + }); + + it('clamps only strictly older events: start-1 is clamped, start and start+1 are kept', () => { + const { bytes } = encode([ev(META, 199), ev(FULL, 199), ev(INC, 200), ev(INC, 201)], { + start: 200, + end: 1000, + }); + expect(shape(decode(bytes))).toEqual(['4@200', '2@200', '3@200', '3@201']); + }); + + it('does not clamp an event exactly at the plan start, and keeps its payload', () => { + const { bytes } = encode([ev(META, 200, { w: 1 }), ev(FULL, 200, { w: 2 })], { + start: 200, + end: 1000, + }); + expect(decode(bytes)).toEqual([ev(META, 200, { w: 1 }), ev(FULL, 200, { w: 2 })]); + }); + + it('clamping keeps the rest of the event untouched and does not mutate the captured entry', () => { + const base = ev(FULL, 50, { deep: { a: [1, 2] } }); + const session = createReplayStreamEncoder().begin({ start: 200, end: 1000 }); + session.write(new CaptureDataEntryBase('replay', 50, ev(META, 50))); + const chunks = [ + ...session.write(new CaptureDataEntryBase('replay', 50, base)), + ...session.end(), + ]; + expect(base.timestamp).toBe(50); + const total = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const c of chunks) { + total.set(c, at); + at += c.length; + } + expect((decode(total)[1] as unknown as typeof base).data).toEqual(base.data); + }); + + it('with no plan start nothing is clamped', () => { + const events = [ev(META, 1), ev(FULL, 1), ev(INC, 5)]; + expect(decode(encode(events, { start: undefined, end: 9 }).bytes)).toEqual(events); + }); + + it('passes non-object payloads after the base through unchanged and drops them before it', () => { + const { bytes } = encode(['junk', ev(META, 1), ev(FULL, 1), 'tail', null]); + expect(decode(bytes)).toEqual([ev(META, 1), ev(FULL, 1), 'tail', null]); + }); + + it('with a plan start, leaves null, primitive and undefined events after the base alone (undefined reads null)', () => { + const { bytes } = encode([ev(META, 300), ev(FULL, 300), null, 'x', undefined, ev(INC, 100)], { + start: 200, + end: 1000, + }); + expect(decode(bytes)).toEqual([ + ev(META, 300), + ev(FULL, 300), + null, + 'x', + null, + { ...ev(INC, 100), timestamp: 200 }, + ]); + }); + + it('streams: output appears before end() and the chunks concatenate to exactly the event array', () => { + const events = [ + ev(META, 1), + ev(FULL, 1), + ...Array.from({ length: 3000 }, (_, i) => ev(INC, 2 + i, { pad: `${i}-${'x'.repeat(100)}` })), + ]; + const session = createReplayStreamEncoder().begin({ start: undefined, end: 9 }); + const chunks: Uint8Array[] = []; + let beforeEnd = 0; + for (const e of events) { + for (const c of session.write(new CaptureDataEntryBase('replay', 1, e))) { + chunks.push(c); + beforeEnd += 1; + } + } + expect(beforeEnd).toBeGreaterThan(1); // several gzip chunks left before end() + chunks.push(...session.end()); + const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.length; + } + expect(decode(bytes)).toEqual(events); + }); + + it('a base whose FullSnapshot cannot be serialized leaves no half base: later events stay orphans', () => { + const cyclic: Record = { type: 2, timestamp: 1 }; + cyclic.self = cyclic; + const session = createReplayStreamEncoder().begin({ start: undefined, end: 9 }); + session.write(new CaptureDataEntryBase('replay', 1, ev(META, 1))); + expect(() => session.write(new CaptureDataEntryBase('replay', 1, cyclic))).toThrow(); + expect(session.write(new CaptureDataEntryBase('replay', 2, ev(INC, 2)))).toEqual([]); + // ...and a later healthy base still opens the stream, with nothing of the failed one before it. + const chunks = [ + ...session.write(new CaptureDataEntryBase('replay', 3, ev(META, 3))), + ...session.write(new CaptureDataEntryBase('replay', 3, ev(FULL, 3))), + ...session.end(), + ]; + const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let at = 0; + for (const c of chunks) { + bytes.set(c, at); + at += c.length; + } + expect(shape(decode(bytes))).toEqual(['4@3', '2@3']); + }); + + it('end() twice and write() after end() do not throw or duplicate output', () => { + const session = createReplayStreamEncoder().begin({ start: undefined, end: 9 }); + session.write(new CaptureDataEntryBase('replay', 1, ev(META, 1))); + session.write(new CaptureDataEntryBase('replay', 1, ev(FULL, 1))); + const first = session.end(); + expect(first.length).toBeGreaterThan(0); + expect(session.end()).toEqual([]); + expect(session.write(new CaptureDataEntryBase('replay', 2, ev(INC, 2)))).toEqual([]); + }); + + it('an object event without a numeric timestamp passes through unclamped', () => { + const { bytes } = encode([ev(META, 300), ev(FULL, 300), { type: 3, data: {} }], { + start: 200, + end: 1000, + }); + expect(decode(bytes)[2]).toEqual({ type: 3, data: {} }); + }); +}); diff --git a/packages/replay/src/stream-encoder.ts b/packages/replay/src/stream-encoder.ts new file mode 100644 index 00000000..b86a645d --- /dev/null +++ b/packages/replay/src/stream-encoder.ts @@ -0,0 +1,100 @@ +// @bugsee/replay — the STREAMING replay encoder: `replay.bin` built one event at a time (core's FileStreamEncoder), so +// a report never holds the window's rrweb events. Same bytes shape as `encodeReplay` (a gzip of the JSON event array), +// plus the two repairs the retained window needs: +// +// * Orphan trim. rrweb events only mean something on top of a Meta (4) + FullSnapshot (2) base. The store keeps whole +// base segments, but the oldest part may still start mid-segment, and a player would apply such events to the wrong DOM. +// Everything before the first Meta that is IMMEDIATELY followed by a FullSnapshot is dropped. No such pair at all means +// the encoder never writes a byte (the member is omitted). +// * Clamp. The store keeps the base that the window's first events depend on even though it is older than the window. +// Events older than the plan start are written with `timestamp = plan.start`, in order: replaying them at one instant +// reproduces the page state at the window start, and the player's timeline equals the report's. +import type { ExportPlan, FileStreamEncoder, StreamEncoderSession } from '@bugsee/core'; +import { Gzip, strToU8 } from 'fflate'; + +const META = 4; +const FULL_SNAPSHOT = 2; + +const typeOf = (event: unknown): number | undefined => + typeof event === 'object' && event !== null ? (event as { type?: number }).type : undefined; + +function clamp(event: unknown, start: number | undefined): unknown { + if (start === undefined || typeof event !== 'object' || event === null) { + return event; + } + return (event as { timestamp: number }).timestamp < start + ? { ...event, timestamp: start } + : event; +} + +function beginSession(plan: ExportPlan): StreamEncoderSession { + let out: Uint8Array[] = []; + let gzip: Gzip | undefined; + let count = 0; + let pendingMeta: unknown; + let hasPendingMeta = false; + let ended = false; + + const text = (event: unknown): string => JSON.stringify(clamp(event, plan.start)) ?? 'null'; + // Writes already-serialized events: serializing happens BEFORE anything is written, so a record that cannot be + // serialized (a cycle, a BigInt) leaves the stream untouched instead of half of a base pair behind. + const emit = (...texts: string[]): void => { + if (gzip === undefined) { + gzip = new Gzip(); + gzip.ondata = (chunk) => { + out.push(chunk); + }; + } + for (const item of texts) { + gzip.push(strToU8((count === 0 ? '[' : ',') + item)); + count += 1; + } + }; + const drain = (): Uint8Array[] => { + const chunks = out; + out = []; + return chunks; + }; + + return { + write(entry): Uint8Array[] { + if (ended) { + return []; // a finished gzip stream takes no more input + } + const event = entry.data; + if (count === 0) { + // Still looking for the base: only a Meta directly followed by a FullSnapshot opens it. + const type = typeOf(event); + if (type === META) { + pendingMeta = event; + hasPendingMeta = true; + } else if (type === FULL_SNAPSHOT && hasPendingMeta) { + emit(text(pendingMeta), text(event)); + hasPendingMeta = false; + pendingMeta = undefined; + } else { + hasPendingMeta = false; + pendingMeta = undefined; + } + } else { + emit(text(event)); + } + return drain(); + }, + end(): Uint8Array[] { + if (ended) { + return []; + } + ended = true; + if (gzip !== undefined) { + gzip.push(strToU8(']'), true); + } + return drain(); + }, + }; +} + +/** The streaming replay encoder to register as the assembler's `streamEncoders.replay`. */ +export function createReplayStreamEncoder(): FileStreamEncoder { + return { begin: beginSession }; +} From 5bc731be46404347b1592d8e49465cd543f04cf9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 00:07:18 +0500 Subject: [PATCH 08/33] feat(core,node-utils,browser-utils): bundle sinks and body-aware transports (streaming export S5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BundleBody.ready/dispose ownership in the upload pipeline, HttpTransport.acceptsBundleBody (node pipe, fetch Blob; others get bytes), memory/Blob/node temp-file sinks with a bounded, ordered write queue that aborts cleanly, withTransportHeaders keeping the flag across the launchers' internal tagging. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 1 + .../browser-utils/src/fetch-transport.test.ts | 44 +++ packages/browser-utils/src/fetch-transport.ts | 24 +- packages/browser/src/launch.ts | 12 +- packages/core/src/bundle-assembler.ts | 3 + packages/core/src/bundle-body.ts | 10 +- packages/core/src/bundle-sink.test.ts | 86 +++++ packages/core/src/bundle-sink.ts | 107 +++++++ .../core/src/bundle-stream-assembler.test.ts | 293 ++++++++++++++++++ packages/core/src/bundle-stream-assembler.ts | 91 +++++- packages/core/src/bundle-uploader.test.ts | 42 ++- packages/core/src/bundle-uploader.ts | 15 +- packages/core/src/index.ts | 9 + packages/core/src/transport-headers.test.ts | 50 +++ packages/core/src/transport-headers.ts | 17 + packages/core/src/transport.ts | 17 +- packages/core/src/upload-pipeline.test.ts | 179 ++++++++++- packages/core/src/upload-pipeline.ts | 34 +- .../src/file-bundle-sink.partial.test.ts | 48 +++ .../node-utils/src/file-bundle-sink.test.ts | 130 ++++++++ packages/node-utils/src/file-bundle-sink.ts | 114 +++++++ packages/node-utils/src/http-request.test.ts | 157 ++++++++++ packages/node-utils/src/http-request.ts | 48 ++- packages/node-utils/src/index.ts | 1 + packages/node/src/launch.ts | 12 +- packages/vercel-edge/src/launch.ts | 9 +- packages/webworker/src/launch.ts | 9 +- 27 files changed, 1487 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/bundle-sink.test.ts create mode 100644 packages/core/src/bundle-sink.ts create mode 100644 packages/core/src/transport-headers.test.ts create mode 100644 packages/core/src/transport-headers.ts create mode 100644 packages/node-utils/src/file-bundle-sink.partial.test.ts create mode 100644 packages/node-utils/src/file-bundle-sink.test.ts create mode 100644 packages/node-utils/src/file-bundle-sink.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 1293b6b0..ba4a8d5d 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -215,6 +215,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | +| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b** durable store streaming `putFrame`. **S5c** client wiring. | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | diff --git a/packages/browser-utils/src/fetch-transport.test.ts b/packages/browser-utils/src/fetch-transport.test.ts index c98c6ecf..a3ba8017 100644 --- a/packages/browser-utils/src/fetch-transport.test.ts +++ b/packages/browser-utils/src/fetch-transport.test.ts @@ -1,3 +1,4 @@ +import { createBlobSink, createMemoryBody } from '@bugsee/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createFetchTransport, type FetchLike, fetchTransport } from './fetch-transport'; @@ -112,6 +113,49 @@ describe('createFetchTransport', () => { }); }); +describe('createFetchTransport — BundleBody', () => { + const capture = () => { + let seenInit: RequestInit | undefined; + const fake: FetchLike = (_url, init) => { + seenInit = init; + return Promise.resolve(new Response('ok')); + }; + return { fake, init: () => seenInit }; + }; + + it('declares that it accepts a BundleBody', () => { + expect(createFetchTransport(capture().fake).acceptsBundleBody).toBe(true); + }); + + it('sends a Blob-backed body as its Blob, without reading it into memory', async () => { + const { fake, init } = capture(); + const sink = createBlobSink(); + const writer = await (sink as NonNullable).open(); + writer.write(new Uint8Array([1, 2, 3])); + const body = await writer.finish(); + const bytes = vi.spyOn(body, 'bytes'); + await createFetchTransport(fake)('https://x.test', { method: 'PUT', body }); + expect(init()?.body).toBeInstanceOf(Blob); + expect((init()?.body as Blob).size).toBe(3); + expect(bytes).not.toHaveBeenCalled(); + }); + + it('sends an in-memory body as its bytes', async () => { + const { fake, init } = capture(); + const body = createMemoryBody([new Uint8Array([1]), new Uint8Array([2, 3])]); + await createFetchTransport(fake)('https://x.test', { method: 'PUT', body }); + expect(Array.from(init()?.body as Uint8Array)).toEqual([1, 2, 3]); + }); + + it('materializes any other body once, as bytes', async () => { + const { fake, init } = capture(); + const base = createMemoryBody([new Uint8Array([7, 8])]); + const body = { ...base, peek: () => undefined }; + await createFetchTransport(fake)('https://x.test', { method: 'PUT', body }); + expect(Array.from(init()?.body as Uint8Array)).toEqual([7, 8]); + }); +}); + describe('fetchTransport (default, over globalThis.fetch)', () => { it('delegates to globalThis.fetch when no fetch is injected', async () => { const globalFetch = vi.fn(() => Promise.resolve(new Response('global'))); diff --git a/packages/browser-utils/src/fetch-transport.ts b/packages/browser-utils/src/fetch-transport.ts index 12e3a244..1535e544 100644 --- a/packages/browser-utils/src/fetch-transport.ts +++ b/packages/browser-utils/src/fetch-transport.ts @@ -1,4 +1,10 @@ -import type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/core'; +import { + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, + isBlobBundleBody, + isBundleBody, +} from '@bugsee/core'; // The browser implementation of core's HttpTransport primitive — the fetch analog of // node-utils `httpRequest`. Wraps the platform `fetch`; non-2xx statuses RESOLVE (the caller maps @@ -18,16 +24,27 @@ export type FetchLike = (url: string, init?: RequestInit) => Promise; export function createFetchTransport(fetchImpl?: FetchLike): HttpTransport { const doFetch: FetchLike = fetchImpl ?? ((url, init) => globalThis.fetch(url, init)); - return async (url: string, options: HttpRequestOptions = {}): Promise => { + const transport = async ( + url: string, + options: HttpRequestOptions = {}, + ): Promise => { const { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const init: RequestInit = { method, headers, signal: controller.signal }; if (body !== undefined) { + // A BundleBody: a Blob-backed one goes as its Blob (the engine reads it from wherever it keeps it); anything else + // is turned into bytes (free for an in-memory one). + const sent = + typeof body !== 'string' && isBundleBody(body) + ? isBlobBundleBody(body) + ? body.blob + : (body.peek() ?? (await body.bytes())) + : body; // lib.dom's `BodyInit` arms don't include the generic `Uint8Array` shape, // but a string and a Uint8Array are both valid request bodies at runtime — widen safely. - init.body = body as BodyInit; + init.body = sent as BodyInit; } const response = await doFetch(url, init); const outHeaders: Record = {}; @@ -50,6 +67,7 @@ export function createFetchTransport(fetchImpl?: FetchLike): HttpTransport { clearTimeout(timer); } }; + return Object.assign(transport, { acceptsBundleBody: true as const }); } /** The default browser transport, over `globalThis.fetch`. */ diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index ddb81e67..4d5835a4 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -60,8 +60,6 @@ import { getOrCreateInterceptor, getProcessStatus, getServiceManifests, - type HttpRequestOptions, - type HttpResponse, type HttpTransport, LifecycleEvents, type LifecycleEventsListener, @@ -82,6 +80,7 @@ import { TransportToken, type TriggerPipeline, type UploadResult, + withTransportHeaders, } from '@bugsee/core'; import { BugseeOption, type EnvironmentEnvelope, TraceName } from '@bugsee/protocol'; import { applicationTrackerFor } from '@bugsee/trackers/browser'; @@ -652,13 +651,8 @@ export interface LaunchResult { // Wrap the transport so EVERY SDK request carries X-Bugsee-Internal — the network capture's default // self-isolation skips it, so the SDK never records its own traffic. -const internalTagged = - (transport: HttpTransport): HttpTransport => - (url: string, options: HttpRequestOptions = {}): Promise => - transport(url, { - ...options, - headers: { ...options.headers, 'x-bugsee-internal': '1' }, - }); +const internalTagged = (transport: HttpTransport): HttpTransport => + withTransportHeaders(transport, { 'x-bugsee-internal': '1' }); function reportInvalidHostOwned(onError: ((error: unknown) => void) | undefined): void { reportGuarded( diff --git a/packages/core/src/bundle-assembler.ts b/packages/core/src/bundle-assembler.ts index 7e6b0c53..fd56c4cd 100644 --- a/packages/core/src/bundle-assembler.ts +++ b/packages/core/src/bundle-assembler.ts @@ -16,6 +16,7 @@ import { } from '@bugsee/protocol'; import type { AttributeValue, IssueType } from '@bugsee/types'; import { resolveUserAttachments } from './attachments'; +import type { BundleSink } from './bundle-sink'; import { type BundleFile, writeBundleZip } from './bundle-writer'; import type { Clock } from './clock'; import type { CaptureDataEntry } from './contracts'; @@ -64,6 +65,8 @@ export interface BundleAssemblyContext { * over `fileEncoders` for the same type. See {@link FileStreamEncoder}. */ streamEncoders?: Partial>; + /** Where the streaming assembler writes the zip as it is built. Default: memory (see {@link BundleSink}). */ + sink?: BundleSink; /** Sink for non-fatal problems (a dropped user attachment). Absent → they are dropped silently. */ onError?: (error: unknown) => void; } diff --git a/packages/core/src/bundle-body.ts b/packages/core/src/bundle-body.ts index 05055d0e..ec72033c 100644 --- a/packages/core/src/bundle-body.ts +++ b/packages/core/src/bundle-body.ts @@ -4,6 +4,11 @@ // Blob, a temp file, or, here, memory chunks. export interface BundleBody { + /** + * Optional pre-flight, awaited BEFORE the upload creates anything on the collector: rejects when the content can no + * longer be read (a temp file that was removed). Bodies that cannot go missing (memory, Blob) omit it. + */ + ready?(): Promise; /** Total length in bytes (the signed PUT's Content-Length). */ readonly size: number; /** The content in order. RESTARTABLE: every call reads from the start, so an upload retry can run again. */ @@ -13,8 +18,9 @@ export interface BundleBody { /** The whole content when it is already in memory, else `undefined`: lets a synchronous writer avoid a copy. */ peek(): Uint8Array | undefined; /** - * Release whatever backs the body (a temp file, a Blob). Repeatable. The CREATOR of a body owns it; the upload - * pipelines do not call this yet (they arrive with the file/Blob sinks), and an in-memory body has nothing to release. + * Release whatever backs the body (a temp file, a Blob). Repeatable and never throws into its caller. Ownership + * passes with the body: the upload pipeline that was handed it disposes it once the upload is over (success or not). + * An in-memory body has nothing to release. */ dispose(): void | Promise; } diff --git a/packages/core/src/bundle-sink.test.ts b/packages/core/src/bundle-sink.test.ts new file mode 100644 index 00000000..cebc50c8 --- /dev/null +++ b/packages/core/src/bundle-sink.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { createMemorySink } from './bundle-sink'; + +describe('createMemorySink', () => { + it('collects chunks in order into a body of the right size and content', async () => { + const writer = await createMemorySink().open(); + writer.write(new Uint8Array([1, 2])); + writer.write(new Uint8Array([3])); + const body = await writer.finish(); + expect(body.size).toBe(3); + expect(Array.from(await body.bytes())).toEqual([1, 2, 3]); + }); + + it('gives every open() its own buffer', async () => { + const sink = createMemorySink(); + const a = await sink.open(); + const b = await sink.open(); + a.write(new Uint8Array([1])); + b.write(new Uint8Array([2, 2])); + expect((await a.finish()).size).toBe(1); + expect((await b.finish()).size).toBe(2); + }); + + it('an aborted writer drops what it held', async () => { + const writer = await createMemorySink().open(); + writer.write(new Uint8Array([1, 2, 3])); + await writer.abort(); + expect((await writer.finish()).size).toBe(0); + }); +}); + +import { createBlobSink, isBlobBundleBody } from './bundle-sink'; + +describe('createBlobSink', () => { + it('is undefined where there is no Blob', () => { + const g = globalThis as unknown as { Blob?: unknown }; + const original = g.Blob; + g.Blob = undefined; + try { + expect(createBlobSink()).toBeUndefined(); + } finally { + g.Blob = original; + } + }); + + it('collects chunks into a Blob-backed body with the right size, bytes and stream', async () => { + const sink = createBlobSink(); + const writer = await (sink as NonNullable).open(); + writer.write(new Uint8Array([1, 2])); + writer.write(new Uint8Array([3, 4, 5])); + const body = await writer.finish(); + expect(body.size).toBe(5); + expect(Array.from(await body.bytes())).toEqual([1, 2, 3, 4, 5]); + expect(body.peek()).toBeUndefined(); + const streamed: number[] = []; + for await (const chunk of body.stream()) streamed.push(...chunk); + expect(streamed).toEqual([1, 2, 3, 4, 5]); + // restartable: a retry reads from the start again + const again: number[] = []; + for await (const chunk of body.stream()) again.push(...chunk); + expect(again).toEqual([1, 2, 3, 4, 5]); + expect(isBlobBundleBody(body)).toBe(true); + await body.dispose(); + }); + + it('builds the Blob without a content type (a typed Blob would add a Content-Type header to the signed PUT)', async () => { + const sink = createBlobSink(); + const writer = await (sink as NonNullable).open(); + writer.write(new Uint8Array([1])); + const body = (await writer.finish()) as unknown as { blob: { type: string } }; + expect(body.blob.type).toBe(''); + }); + + it('a plain memory body is not a Blob body', async () => { + const writer = await createMemorySink().open(); + expect(isBlobBundleBody(await writer.finish())).toBe(false); + }); + + it('an aborted writer drops its parts', async () => { + const sink = createBlobSink(); + const writer = await (sink as NonNullable).open(); + writer.write(new Uint8Array([1, 2, 3])); + await writer.abort(); + expect((await writer.finish()).size).toBe(0); + }); +}); diff --git a/packages/core/src/bundle-sink.ts b/packages/core/src/bundle-sink.ts new file mode 100644 index 00000000..79c942bc --- /dev/null +++ b/packages/core/src/bundle-sink.ts @@ -0,0 +1,107 @@ +import { type BundleBody, createMemoryBody } from './bundle-body'; + +// Where an assembled bundle's bytes go while the zip is being built (docs/design/streaming-export.md §4.5). The streaming +// assembler writes the zip chunk by chunk to a sink writer and gets a BundleBody back, so a platform decides what holds a +// large artifact: a Blob (the engine may spill it to disk), a temp file (node), or plain memory (the default, edge). + +export interface BundleSinkWriter { + /** The next chunk of the zip, in order. May be async: the assembler waits for it before producing much more. */ + write(chunk: Uint8Array): void | Promise; + /** The zip is complete: the artifact. */ + finish(): Promise; + /** The export failed: drop whatever was written (delete the partial file). Never throws into the caller. */ + abort(): void | Promise; +} + +export interface BundleSink { + open(): BundleSinkWriter | Promise; +} + +/** The default sink: chunks held in memory (today's behaviour, minus the intermediate copies). */ +export function createMemorySink(): BundleSink { + return { + open(): BundleSinkWriter { + const chunks: Uint8Array[] = []; + return { + write: (chunk): void => { + chunks.push(chunk); + }, + finish: (): Promise => Promise.resolve(createMemoryBody(chunks)), + abort: (): void => { + chunks.length = 0; + }, + }; + }, + }; +} + +/** The slice of the platform `Blob` the Blob sink needs (core has no DOM types). */ +interface BlobLike { + readonly size: number; + stream(): { + getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }>; releaseLock(): void }; + }; + arrayBuffer(): Promise; +} +type BlobConstructorLike = new (parts: Uint8Array[]) => BlobLike; + +/** A {@link BundleBody} backed by a Blob; `blob` is what a fetch transport can send as is. */ +export interface BlobBundleBody extends BundleBody { + readonly blob: BlobLike; +} + +export function isBlobBundleBody(body: BundleBody): body is BlobBundleBody { + return 'blob' in body && (body as { blob?: unknown }).blob !== undefined; +} + +/** + * A sink that collects the zip as Blob parts, for runtimes that have `Blob` (browsers, workers, Deno, Bun, Node 18+): the + * engine is free to keep a large Blob out of the JS heap. `undefined` where the global is missing. + */ +export function createBlobSink(): BundleSink | undefined { + const BlobCtor = (globalThis as unknown as { Blob?: BlobConstructorLike }).Blob; + if (BlobCtor === undefined) { + return undefined; + } + return { + open(): BundleSinkWriter { + let parts: Uint8Array[] = []; + return { + write: (chunk): void => { + parts.push(chunk); + }, + finish: (): Promise => { + const blob = new BlobCtor(parts); + parts = []; + const body: BlobBundleBody = { + blob, + size: blob.size, + stream: (): AsyncIterable => ({ + async *[Symbol.asyncIterator](): AsyncGenerator { + const reader = blob.stream().getReader(); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + return; + } + yield value as Uint8Array; + } + } finally { + reader.releaseLock(); + } + }, + }), + bytes: async (): Promise => new Uint8Array(await blob.arrayBuffer()), + peek: (): undefined => undefined, + dispose: (): void => {}, + }; + return Promise.resolve(body); + }, + abort: (): void => { + parts = []; + }, + }; + }, + }; +} diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 2c3e0419..59b4a06f 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; import { strFromU8, strToU8, unzipSync } from '@bugsee/util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +import { createMemoryBody } from './bundle-body'; import { assembleBundleStream } from './bundle-stream-assembler'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; @@ -795,3 +796,295 @@ describe('assembleBundleStream — time plan', () => { }); }); }); + +describe('assembleBundleStream — sink', () => { + const NOWS = 1_700_000_000_000; + const fakeSink = (opts: { failWriteAt?: number; slow?: boolean; failFinish?: boolean } = {}) => { + const events: string[] = []; + const written: Uint8Array[] = []; + let inFlight = 0; + let maxInFlight = 0; + const sink = { + open: () => { + events.push('open'); + return { + write: async (chunk: Uint8Array) => { + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + if (opts.slow === true) for (let i = 0; i < 5; i++) await Promise.resolve(); + if (opts.failWriteAt !== undefined && written.length === opts.failWriteAt) { + inFlight -= 1; + throw new Error('disk full'); + } + written.push(chunk); + inFlight -= 1; + }, + finish: async () => { + events.push('finish'); + if (opts.failFinish === true) throw new Error('finish failed'); + return createMemoryBody(written); + }, + abort: async () => { + events.push('abort'); + }, + }; + }, + }; + return { sink, events, written, maxInFlight: () => maxInFlight }; + }; + const request = () => createReportingRequest({ source: { type: 'error' }, id: 'r1' }); + + it('writes the zip to the sink and returns the body the sink finished with', async () => { + const { sink, events, written } = fakeSink(); + const bundle = await assembleBundleStream( + request(), + from([entry('log', NOWS - 5, 'a')]), + context({ sink }), + ); + expect(events).toEqual(['open', 'finish']); + expect(written.length).toBeGreaterThan(0); + const files = members(await bundle.body.bytes()); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['a']); + expect(Array.from(await bundle.body.bytes())).toEqual( + Array.from(written.flatMap((c) => Array.from(c))), + ); + }); + + it('produces the same archive through a custom sink as through the default one', async () => { + const entries = [entry('log', NOWS - 5, 'a'), entry('network', NOWS - 4, { u: 1 })]; + const viaDefault = await assembleBundleStream(request(), from(entries), context()); + const { sink } = fakeSink(); + const viaSink = await assembleBundleStream(request(), from(entries), context({ sink })); + expect(normalized(members(await viaSink.body.bytes()))).toEqual( + normalized(members(await viaDefault.body.bytes())), + ); + }); + + it('aborts the writer, closes the source and rethrows when the sink fails to write', async () => { + const { sink, events } = fakeSink({ failWriteAt: 0 }); + let closed = false; + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => { + const it = from([entry('log', NOWS - 5, 'a')])[Symbol.asyncIterator](); + return { + next: () => it.next(), + return: async () => { + closed = true; + return { done: true as const, value: undefined }; + }, + }; + }, + }; + await expect(assembleBundleStream(request(), source, context({ sink }))).rejects.toThrow( + 'disk full', + ); + expect(events).toContain('abort'); + expect(events).not.toContain('finish'); + expect(closed).toBe(true); + }); + + it('fails the bundle when only the LAST chunks cannot be written (the end-of-build wait)', async () => { + const probe = fakeSink(); + await assembleBundleStream( + request(), + from([entry('log', NOWS - 5, 'a')]), + context({ sink: probe.sink }), + ); + const total = probe.written.length; + const { sink, events } = fakeSink({ failWriteAt: total - 1 }); + await expect( + assembleBundleStream(request(), from([entry('log', NOWS - 5, 'a')]), context({ sink })), + ).rejects.toThrow('disk full'); + expect(events).toContain('abort'); + expect(events).not.toContain('finish'); + }); + + it('lets an in-flight write finish before it aborts the writer (no write after abort)', async () => { + const order: string[] = []; + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const sink = { + open: () => ({ + write: async () => { + order.push('write-start'); + await gate; + order.push('write-end'); + }, + finish: async () => createMemoryBody([]), + abort: async () => { + order.push('abort'); + }, + }), + }; + const crash: CrashJson = { + exception_type: 'error', + ndkCrash: false, + handled: true, + exception: { name: 'E', reason: 'r', frames: [] }, + }; + const pending = assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1', crash }), + from([entry('crash', 1, { x: 1 })]), + context({ sink }), + ).catch((e: unknown) => e); + for (let i = 0; i < 100; i++) await Promise.resolve(); + expect(order).not.toContain('abort'); // still waiting for the blocked write + release(); + expect(await pending).toBeInstanceOf(Error); + expect(order.filter((e) => e === 'abort')).toHaveLength(1); + expect(order[order.length - 1]).toBe('abort'); // nothing is written after the abort + expect(order.filter((e) => e === 'write-start').length).toBe( + order.filter((e) => e === 'write-end').length, + ); + }); + + it('aborts the writer when finish() fails', async () => { + const { sink, events } = fakeSink({ failFinish: true }); + await expect(assembleBundleStream(request(), from([]), context({ sink }))).rejects.toThrow( + 'finish failed', + ); + expect(events).toEqual(['open', 'finish', 'abort']); + }); + + it('aborts the writer when the build itself fails (a duplicate member name)', async () => { + const { sink, events } = fakeSink(); + const crash: CrashJson = { + exception_type: 'error', + ndkCrash: false, + handled: true, + exception: { name: 'E', reason: 'r', frames: [] }, + }; + await expect( + assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1', crash }), + from([entry('crash', 1, { x: 1 })]), + context({ sink }), + ), + ).rejects.toThrow(/duplicate file name/); + expect(events).toContain('abort'); + }); + + it('a failing open() fails the bundle and closes the source', async () => { + let closed = false; + const source: AsyncIterable = { + [Symbol.asyncIterator]: () => ({ + next: async () => ({ done: true as const, value: undefined }), + return: async () => { + closed = true; + return { done: true as const, value: undefined }; + }, + }), + }; + const sink = { open: () => Promise.reject(new Error('no temp dir')) }; + await expect(assembleBundleStream(request(), source, context({ sink }))).rejects.toThrow( + 'no temp dir', + ); + expect(closed).toBe(true); + }); + + // Incompressible text, so the zip output is as big as the input and a slow sink actually backs up. + const noise = (seed: number, length: number): string => { + let x = seed; + let out = ''; + for (let i = 0; i < length; i++) { + x = (x * 1664525 + 1013904223) >>> 0; + out += String.fromCharCode(33 + ((x >>> 24) % 90)); + } + return out; + }; + + it('does not read ahead of a slow sink: the backlog stays bounded', async () => { + let pulled = 0; + async function* source(): AsyncIterable { + for (let i = 0; i < 30; i++) { + pulled += 1; + yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + } + } + const { sink, written, maxInFlight } = fakeSink({ slow: true }); + const pulledAtWrite: number[] = []; + const slowSink = { + open: async () => { + const writer = await sink.open(); + return { + ...writer, + write: async (chunk: Uint8Array) => { + pulledAtWrite.push(pulled); + await writer.write(chunk); + }, + }; + }, + }; + await assembleBundleStream(request(), source(), context({ sink: slowSink })); + expect(written.length).toBeGreaterThan(1); + expect(maxInFlight()).toBe(1); // writes are strictly sequential + // Written chunks lag the reader by at most the backlog bound (a few entries), never the whole export. + expect(Math.min(...pulledAtWrite.slice(0, 5))).toBeLessThan(30); + expect(pulledAtWrite[0]).toBeLessThanOrEqual(8); + }); + + it('stops reading entries once the sink has failed', async () => { + let pulled = 0; + async function* source(): AsyncIterable { + for (let i = 0; i < 300; i++) { + pulled += 1; + yield entry('log', NOWS - 100 + i, noise(i + 1, 2000)); + } + } + const { sink } = fakeSink({ failWriteAt: 0 }); + await expect(assembleBundleStream(request(), source(), context({ sink }))).rejects.toThrow( + 'disk full', + ); + expect(pulled).toBeLessThan(5); + }); + + it('holds the read-ahead to a bounded backlog while the sink is blocked, then completes once it is released', async () => { + let pulled = 0; + async function* source(): AsyncIterable { + for (let i = 0; i < 40; i++) { + pulled += 1; + yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + } + } + let open!: () => void; + const gate = new Promise((resolve) => (open = resolve)); + const written: Uint8Array[] = []; + const sink = { + open: () => ({ + write: async (chunk: Uint8Array) => { + await gate; + written.push(chunk); + }, + finish: async () => createMemoryBody(written), + abort: async () => {}, + }), + }; + const pending = assembleBundleStream(request(), source(), context({ sink })); + for (let i = 0; i < 200; i++) await Promise.resolve(); + expect(pulled).toBeLessThan(40); // blocked sink: the reader did not run away with the whole export + open(); + await pending; + expect(pulled).toBe(40); + }); + + it('stops writing to a sink after its first failed write', async () => { + let calls = 0; + const sink = { + open: () => ({ + write: async () => { + calls += 1; + throw new Error('disk full'); + }, + finish: async () => createMemoryBody([]), + abort: async () => {}, + }), + }; + const entries = Array.from({ length: 200 }, (_, i) => + entry('log', NOWS - 100 + i, noise(i + 1, 2000)), + ); + await expect(assembleBundleStream(request(), from(entries), context({ sink }))).rejects.toThrow( + 'disk full', + ); + expect(calls).toBe(1); + }); +}); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index 93a91ae6..c706d093 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -16,7 +16,8 @@ import { manifestEntryForType, randomBundleFileName, } from './bundle-assembler'; -import { type BundleBody, createMemoryBody } from './bundle-body'; +import type { BundleBody } from './bundle-body'; +import { type BundleSinkWriter, createMemorySink } from './bundle-sink'; import type { CaptureDataEntry } from './contracts'; import type { StreamEncoderSession } from './file-stream-encoder'; import type { ReportingRequest } from './reporting'; @@ -78,11 +79,88 @@ async function build( context: BundleAssemblyContext, extra: ReadonlyMap | undefined, plan: TimeRange | undefined, +): Promise { + const writer = await (context.sink ?? defaultSink).open(); + const queue = createWriteQueue(writer); + try { + return await buildInto(writer, queue, request, iterator, context, extra, plan); + } catch (error) { + try { + await queue.idle(); // a write still in flight must land (or fail) before the artifact is deleted under it + await writer.abort(); // drop the partial artifact; the ORIGINAL failure is what the caller needs + } catch { + // Aborting failed too. + } + throw error; + } +} + +interface WriteQueue { + add(chunk: Uint8Array): void; + /** Bytes still waiting for the sink. */ + readonly queued: number; + /** Wait for every queued write, then throw the first sink failure if there was one. */ + settle(): Promise; + /** Wait for every queued write; never throws. */ + idle(): Promise; + throwIfFailed(): void; +} + +/** + * The zip's output goes to the sink writer strictly in order: each write starts when the previous one is done. A failed + * write is remembered (and the later ones skipped) so the build fails with it. + */ +function createWriteQueue(writer: BundleSinkWriter): WriteQueue { + let writes: Promise = Promise.resolve(); + let queued = 0; + let failure: { error: unknown } | undefined; + const throwIfFailed = (): void => { + if (failure !== undefined) { + throw failure.error; + } + }; + return { + add(chunk): void { + queued += chunk.length; + writes = writes.then(async () => { + if (failure === undefined) { + try { + await writer.write(chunk); + } catch (error) { + failure = { error }; + } + } + queued -= chunk.length; + }); + }, + get queued(): number { + return queued; + }, + async settle(): Promise { + await writes; + throwIfFailed(); + }, + idle: () => writes, + throwIfFailed, + }; +} + +/** Writes beyond this many queued bytes make the build wait for the sink instead of racing ahead of it. */ +const MAX_QUEUED_BYTES = 1 << 20; +const defaultSink = createMemorySink(); + +async function buildInto( + writer: BundleSinkWriter, + queue: WriteQueue, + request: ReportingRequest, + iterator: AsyncIterator, + context: BundleAssemblyContext, + extra: ReadonlyMap | undefined, + plan: TimeRange | undefined, ): Promise { const now = context.clock.wallNow(); const requestJson = buildRequestJson(request, context, now); - const chunks: Uint8Array[] = []; const done = createDeferred(); const zip = new Zip((error, data, final) => { /* v8 ignore next 4 -- fflate's streaming deflate is synchronous and reports no error for in-memory input; the guard keeps the contract if that ever changes */ @@ -90,7 +168,7 @@ async function build( done.reject(error); return; } - chunks.push(data); + queue.add(data); if (final) { done.resolve(); } @@ -196,6 +274,10 @@ async function build( }; for await (const entry of { [Symbol.asyncIterator]: () => iterator }) { take(entry, true); + queue.throwIfFailed(); // the sink already failed: stop reading, nothing more can be delivered + if (queue.queued > MAX_QUEUED_BYTES) { + await queue.settle(); // the sink is behind: let it catch up before reading more + } } for (const list of extra?.values() ?? []) { for (const entry of list) { @@ -237,9 +319,10 @@ async function build( addWhole(MANIFEST_JSON_FILENAME, JSON.stringify(buildManifest(context, start, now, files))); zip.end(); await done.promise; + await queue.settle(); return { request: requestJson, - body: createMemoryBody(chunks), + body: await writer.finish(), fileName: (context.fileName ?? randomBundleFileName)(), }; } diff --git a/packages/core/src/bundle-uploader.test.ts b/packages/core/src/bundle-uploader.test.ts index 48344967..00f1c892 100644 --- a/packages/core/src/bundle-uploader.test.ts +++ b/packages/core/src/bundle-uploader.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { createMemoryBody } from './bundle-body'; import { createBundleUploader } from './bundle-uploader'; import type { HttpRequestOptions, HttpResponse, PutBundleOptions } from './transport'; @@ -152,3 +153,42 @@ describe('createBundleUploader', () => { expect((result as { cause?: unknown }).cause).toBe(boom); }); }); + +describe('createBundleUploader — body kinds', () => { + const memoryBody = createMemoryBody([new Uint8Array([1, 2]), new Uint8Array([3])]); + + it('hands a transport that does not declare BundleBody support the whole bytes, never the body', async () => { + const { transport, calls } = fakeTransport(() => res(200)); + await createBundleUploader(transport).putBundle('u', memoryBody, opts); + const sent = calls[0]?.options?.body as Uint8Array; + expect(ArrayBuffer.isView(sent)).toBe(true); + expect(Array.from(sent)).toEqual([1, 2, 3]); + }); + + it('hands a transport that declares support the body itself (no materializing)', async () => { + const { transport, calls } = fakeTransport(() => res(200)); + const bytes = vi.fn(memoryBody.bytes); + const body = { ...memoryBody, bytes }; + await createBundleUploader( + Object.assign(transport, { acceptsBundleBody: true as const }), + ).putBundle('u', body, opts); + expect(calls[0]?.options?.body).toBe(body); + expect(bytes).not.toHaveBeenCalled(); + }); + + it('passes raw bytes through untouched even to a transport that declares support', async () => { + const { transport, calls } = fakeTransport(() => res(200)); + await createBundleUploader( + Object.assign(transport, { acceptsBundleBody: true as const }), + ).putBundle('u', body, opts); + expect(calls[0]?.options?.body).toBe(body); + }); + + it('maps a body that cannot be read to a retryable failure with its cause', async () => { + const { transport } = fakeTransport(() => res(200)); + const broken = { ...memoryBody, bytes: () => Promise.reject(new Error('gone')) }; + const result = await createBundleUploader(transport).putBundle('u', broken, opts); + expect(result).toMatchObject({ ok: false, status: 0, retryable: true }); + expect((result as { cause?: Error }).cause?.message).toBe('gone'); + }); +}); diff --git a/packages/core/src/bundle-uploader.ts b/packages/core/src/bundle-uploader.ts index 651a2ec0..2bfa2d00 100644 --- a/packages/core/src/bundle-uploader.ts +++ b/packages/core/src/bundle-uploader.ts @@ -1,3 +1,4 @@ +import { asBody, type BundleBodyLike, isBundleBody } from './bundle-body'; import { type BundleUploader, type HttpResponse, @@ -19,9 +20,19 @@ import { export function createBundleUploader(transport: HttpTransport): BundleUploader { return { - async putBundle(url: string, body: Uint8Array, options: PutBundleOptions): Promise { + async putBundle( + url: string, + body: BundleBodyLike, + options: PutBundleOptions, + ): Promise { let response: HttpResponse; try { + // A transport that declares it can send a BundleBody gets it as is (a pipe / a Blob, nothing materialized); every + // other one, third-party injections included, gets the plain bytes it always has. + const sendBody = + isBundleBody(body) && transport.acceptsBundleBody !== true + ? await asBody(body).bytes() + : body; response = await transport(url, { method: 'PUT', headers: { @@ -36,7 +47,7 @@ export function createBundleUploader(transport: HttpTransport): BundleUploader { // signable — and only then send it again. fileName: options.fileName, }, - body, + body: sendBody, }); } catch (cause) { // Carry the error: status 0 alone says only "the request never completed", which is the same diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1807bd7e..21136a65 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -63,6 +63,14 @@ export { createMemoryBody, isBundleBody, } from './bundle-body'; +export { + type BlobBundleBody, + type BundleSink, + type BundleSinkWriter, + createBlobSink, + createMemorySink, + isBlobBundleBody, +} from './bundle-sink'; export { assembleBundleStream } from './bundle-stream-assembler'; export { createBundleUploader } from './bundle-uploader'; export { type BundleFile, writeBundleZip } from './bundle-writer'; @@ -360,6 +368,7 @@ export { TransportToken, UploadPipelineToken, } from './transport'; +export { withTransportHeaders } from './transport-headers'; export { createTriggerPipeline, type TriggerPipeline, diff --git a/packages/core/src/transport-headers.test.ts b/packages/core/src/transport-headers.test.ts new file mode 100644 index 00000000..7d05feaf --- /dev/null +++ b/packages/core/src/transport-headers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import type { HttpRequestOptions, HttpResponse, HttpTransport } from './transport'; +import { withTransportHeaders } from './transport-headers'; + +const ok: HttpResponse = { status: 200, headers: {}, body: new Uint8Array() }; +const recording = (flag = false) => { + const calls: Array<{ url: string; options: HttpRequestOptions | undefined }> = []; + const base = async (url: string, options?: HttpRequestOptions): Promise => { + calls.push({ url, options }); + return ok; + }; + const transport: HttpTransport = flag + ? Object.assign(base, { acceptsBundleBody: true as const }) + : base; + return { transport, calls }; +}; + +describe('withTransportHeaders', () => { + it('adds the headers to a request and keeps the caller headers, method, body and timeout', async () => { + const { transport, calls } = recording(); + const body = new Uint8Array([1]); + await withTransportHeaders(transport, { 'x-bugsee-internal': '1' })('https://x', { + method: 'PUT', + headers: { a: 'b' }, + body, + timeoutMs: 5, + }); + expect(calls[0]).toEqual({ + url: 'https://x', + options: { method: 'PUT', headers: { a: 'b', 'x-bugsee-internal': '1' }, body, timeoutMs: 5 }, + }); + }); + + it('works without options', async () => { + const { transport, calls } = recording(); + await withTransportHeaders(transport, { t: '1' })('https://x'); + expect(calls[0]?.options).toEqual({ headers: { t: '1' } }); + }); + + it('the added headers win over a caller header of the same name', async () => { + const { transport, calls } = recording(); + await withTransportHeaders(transport, { t: 'sdk' })('https://x', { headers: { t: 'caller' } }); + expect(calls[0]?.options?.headers).toEqual({ t: 'sdk' }); + }); + + it('keeps acceptsBundleBody when the inner transport has it, and does not invent it otherwise', () => { + expect(withTransportHeaders(recording(true).transport, {}).acceptsBundleBody).toBe(true); + expect(withTransportHeaders(recording(false).transport, {}).acceptsBundleBody).toBeUndefined(); + }); +}); diff --git a/packages/core/src/transport-headers.ts b/packages/core/src/transport-headers.ts new file mode 100644 index 00000000..42b98564 --- /dev/null +++ b/packages/core/src/transport-headers.ts @@ -0,0 +1,17 @@ +import type { HttpRequestOptions, HttpResponse, HttpTransport } from './transport'; + +/** + * Wrap a transport so every request carries extra headers (the launchers tag SDK traffic `x-bugsee-internal`). The wrapper + * keeps the inner transport's `acceptsBundleBody` flag: dropping it would silently turn every streamed upload back into a + * whole-bundle read. + */ +export function withTransportHeaders( + transport: HttpTransport, + headers: Readonly>, +): HttpTransport { + const wrapped = (url: string, options: HttpRequestOptions = {}): Promise => + transport(url, { ...options, headers: { ...options.headers, ...headers } }); + return transport.acceptsBundleBody === true + ? Object.assign(wrapped, { acceptsBundleBody: true as const }) + : wrapped; +} diff --git a/packages/core/src/transport.ts b/packages/core/src/transport.ts index 4f811381..b028f602 100644 --- a/packages/core/src/transport.ts +++ b/packages/core/src/transport.ts @@ -1,7 +1,7 @@ import type { EnvironmentEnvelope, RequestJson } from '@bugsee/protocol'; import { serviceToken } from '@bugsee/service'; import type { AccessToken, IssueId, RecordingId } from '@bugsee/types'; -import type { BundleBodyLike } from './bundle-body'; +import type { BundleBody, BundleBodyLike } from './bundle-body'; import type { BugseeError } from './errors'; // Transport split into three roles. The BugseeApi / BundleUploader / UploadPipeline @@ -15,7 +15,16 @@ import type { BugseeError } from './errors'; * piece of the transport). node-utils `httpRequest` implements this over node:http(s); a browser * tier wraps fetch/XHR. Non-2xx resolves (callers map status); only network errors/timeouts reject. */ -export type HttpTransport = (url: string, options?: HttpRequestOptions) => Promise; +export type HttpTransport = (( + url: string, + options?: HttpRequestOptions, +) => Promise) & { + /** + * Set by a transport that can send a {@link BundleBody} as a request body without materializing it (a node pipe, + * a browser Blob). Every other transport, including third-party injections, is handed plain bytes. + */ + readonly acceptsBundleBody?: true; +}; export interface HttpRequestOptions { /** HTTP method. Default 'GET'. */ @@ -23,7 +32,7 @@ export interface HttpRequestOptions { /** Request headers. */ headers?: Record; /** Request body. */ - body?: Uint8Array | string; + body?: Uint8Array | string | BundleBody; /** Abort + reject after this many ms. */ timeoutMs?: number; } @@ -134,7 +143,7 @@ export interface PutBundleOptions { /** DATA PLANE — unauthenticated signed PUT; mirrors iOS PUT headers. */ export interface BundleUploader { - putBundle(url: string, body: Uint8Array, options: PutBundleOptions): Promise; + putBundle(url: string, body: BundleBodyLike, options: PutBundleOptions): Promise; } /** A fully-assembled, ready-to-upload bundle (output of the trigger path). */ diff --git a/packages/core/src/upload-pipeline.test.ts b/packages/core/src/upload-pipeline.test.ts index 9c33423d..0c3f230f 100644 --- a/packages/core/src/upload-pipeline.test.ts +++ b/packages/core/src/upload-pipeline.test.ts @@ -91,7 +91,7 @@ describe('createUploadPipeline — happy path', () => { }); }); - it('uploads a BundleBody: its size is the content length and its bytes are hashed and PUT', async () => { + it('uploads a BundleBody: its size is the content length, its bytes are hashed, and the body itself is PUT', async () => { const put = vi.fn(async () => ({ ok: true }) as PutResult); const sha256 = vi.fn(async () => 'cafe'); const body = createMemoryBody([new Uint8Array([1, 2]), new Uint8Array([3, 4, 5])]); @@ -102,21 +102,38 @@ describe('createUploadPipeline — happy path', () => { expect(Array.from((sha256.mock.calls[0] as unknown as [Uint8Array])[0])).toEqual([ 1, 2, 3, 4, 5, ]); - const [, sent, options] = put.mock.calls[0] as unknown as [ - string, - Uint8Array, - PutBundleOptions, - ]; - expect(Array.from(sent)).toEqual([1, 2, 3, 4, 5]); + const [, sent, options] = put.mock.calls[0] as unknown as [string, unknown, PutBundleOptions]; + expect(sent).toBe(body); expect(options).toEqual({ contentLength: 5, checksumSha256: 'cafe', fileName: 'a.bundle.zip' }); }); + it('a body that is not held in memory is uploaded by its size and left unhashed (never materialized)', async () => { + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const sha256 = vi.fn(async () => 'cafe'); + const bytes = vi.fn(async () => new Uint8Array([9])); + const body = { + ...createMemoryBody([new Uint8Array([1, 2, 3, 4])]), + peek: () => undefined, + bytes, + }; + await createUploadPipeline(deps({ uploader: fakeUploader(put), sha256 })).enqueue({ + ...bundle, + body, + }); + expect(sha256).not.toHaveBeenCalled(); + expect(bytes).not.toHaveBeenCalled(); + expect((put.mock.calls[0] as unknown as [string, unknown, PutBundleOptions])[2]).toEqual({ + contentLength: 4, + fileName: 'a.bundle.zip', + }); + }); + it('a body that cannot be read fails the upload (no throw, nothing created on the collector), retryable', async () => { const api = fakeApi(); const put = vi.fn(async () => ({ ok: true }) as PutResult); const unreadable = { ...createMemoryBody([new Uint8Array([1])]), - bytes: () => Promise.reject(new Error('temp file gone')), + ready: () => Promise.reject(new Error('temp file gone')), }; const result = await createUploadPipeline(deps({ api, uploader: fakeUploader(put) })).enqueue({ ...bundle, @@ -143,7 +160,7 @@ describe('createUploadPipeline — happy path', () => { ...bundle, body: { ...createMemoryBody([new Uint8Array([1])]), - bytes: () => Promise.reject(new Error('x')), + ready: () => Promise.reject(new Error('x')), }, }); const next = pipeline.enqueue(bundle); // waits behind the failing one (bufferSize 1) @@ -157,7 +174,7 @@ describe('createUploadPipeline — happy path', () => { const mk = (reason: unknown) => createUploadPipeline(deps({})).enqueue({ ...bundle, - body: { ...createMemoryBody([new Uint8Array([1])]), bytes: () => Promise.reject(reason) }, + body: { ...createMemoryBody([new Uint8Array([1])]), ready: () => Promise.reject(reason) }, }); const own = new BugseeError('disk', 0); expect((await mk(own)) as { error: unknown }).toMatchObject({ error: own }); @@ -167,17 +184,15 @@ describe('createUploadPipeline — happy path', () => { expect(wrapped.error.cause).toBe(cause); }); - it('declares the length of the bytes it actually sends, even if the body reports another size', async () => { + it("declares the body's own size as the content length (the body is the authority)", async () => { const put = vi.fn(async () => ({ ok: true }) as PutResult); const liar = { ...createMemoryBody([new Uint8Array([1, 2, 3])]), size: 99 }; await createUploadPipeline(deps({ uploader: fakeUploader(put) })).enqueue({ ...bundle, body: liar, }); - expect( - (put.mock.calls[0] as unknown as [string, Uint8Array, PutBundleOptions])[2], - ).toMatchObject({ - contentLength: 3, + expect((put.mock.calls[0] as unknown as [string, unknown, PutBundleOptions])[2]).toMatchObject({ + contentLength: 99, }); }); @@ -910,3 +925,137 @@ describe('createUploadPipeline + createBugseeApi — the control plane end to en expect(result.error?.serverCode).toBeUndefined(); }); }); + +describe('createUploadPipeline — body ownership', () => { + const disposable = (over: { dispose?: () => void | Promise } = {}) => { + const dispose = vi.fn(over.dispose ?? (() => {})); + return { body: { ...createMemoryBody([new Uint8Array([1, 2, 3])]), dispose }, dispose }; + }; + + it('disposes the body after a successful upload', async () => { + const { body, dispose } = disposable(); + await createUploadPipeline(deps({})).enqueue({ ...bundle, body }); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('disposes the body after a failed upload too (the durable copy, if any, is separate)', async () => { + const put = vi.fn(async () => ({ ok: false, status: 400, retryable: false }) as PutResult); + const { body, dispose } = disposable(); + const result = await createUploadPipeline(deps({ uploader: fakeUploader(put) })).enqueue({ + ...bundle, + body, + }); + expect(result.ok).toBe(false); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('disposes the body of a refused (queue overflow) upload', async () => { + const put = vi.fn(async () => new Promise(() => {})); + const pipeline = createUploadPipeline( + deps({ uploader: fakeUploader(put), bufferSize: 1, maxWaiting: 0 }), + ); + void pipeline.enqueue(bundle); + const { body, dispose } = disposable(); + await pipeline.enqueue({ ...bundle, body }); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('does not dispose while the upload is still running', async () => { + let release: (value: PutResult) => void = () => {}; + const put = vi.fn(() => new Promise((resolve) => (release = resolve))); + const { body, dispose } = disposable(); + const pending = createUploadPipeline(deps({ uploader: fakeUploader(put) })).enqueue({ + ...bundle, + body, + }); + await vi.waitFor(() => expect(put).toHaveBeenCalled()); + expect(dispose).not.toHaveBeenCalled(); + release({ ok: true }); + await pending; + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('a dispose that throws or rejects never changes the upload result and leaves no unhandled rejection', async () => { + const unhandled: unknown[] = []; + const proc = ( + globalThis as unknown as { + process: { + on: (e: string, f: (r: unknown) => void) => void; + off: (e: string, f: (r: unknown) => void) => void; + }; + } + ).process; + const listener = (reason: unknown): void => { + unhandled.push(reason); + }; + proc.on('unhandledRejection', listener); + try { + for (const dispose of [ + () => { + throw new Error('sync'); + }, + () => Promise.reject(new Error('async')), + ]) { + // A plain function, not vi.fn: a mock attaches its own handler to a returned promise and would hide the leak. + const body = { ...createMemoryBody([new Uint8Array([1, 2, 3])]), dispose }; + const result = await createUploadPipeline(deps({})).enqueue({ ...bundle, body }); + expect(result.ok).toBe(true); + } + // unhandledRejection is raised after a macrotask turn, so wait for one + const timer = ( + globalThis as unknown as { setTimeout: (f: () => void, ms: number) => unknown } + ).setTimeout; + await new Promise((resolve) => timer(() => resolve(), 20)); + expect(unhandled).toEqual([]); + } finally { + proc.off('unhandledRejection', listener); + } + }); + + it('keeps and re-reads the same body across a retry and a renew, and disposes it once, after the last PUT', async () => { + const events: string[] = []; + const { body, dispose } = disposable({ dispose: () => void events.push('dispose') }); + const sent: unknown[] = []; + const results: PutResult[] = [ + { ok: false, status: 500, retryable: true }, + { ok: false, status: 403, retryable: false }, + { ok: true }, + ]; + const put = vi.fn(async (_url: string, b: unknown) => { + sent.push(b); + events.push('put'); + return results.shift() as PutResult; + }); + const result = await createUploadPipeline( + deps({ uploader: fakeUploader(put as never) }), + ).enqueue({ + ...bundle, + body, + }); + expect(result.ok).toBe(true); + expect(sent).toHaveLength(3); + expect(sent.every((b) => b === body)).toBe(true); + expect(events).toEqual(['put', 'put', 'put', 'dispose']); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('checks the body is readable once (before the issue), not per attempt', async () => { + const readyCalls: number[] = []; + const base = createMemoryBody([new Uint8Array([1, 2, 3])]); + const body = { ...base, ready: async () => void readyCalls.push(1) }; + const results: PutResult[] = [{ ok: false, status: 500, retryable: true }, { ok: true }]; + const put = vi.fn(async () => results.shift() as PutResult); + const api = fakeApi(); + await createUploadPipeline(deps({ api, uploader: fakeUploader(put) })).enqueue({ + ...bundle, + body, + }); + expect(readyCalls).toHaveLength(1); + expect(put).toHaveBeenCalledTimes(2); + }); + + it('raw bytes need no disposal (nothing to call)', async () => { + const result = await createUploadPipeline(deps({})).enqueue(bundle); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/core/src/upload-pipeline.ts b/packages/core/src/upload-pipeline.ts index 36f28a82..abd2d1b4 100644 --- a/packages/core/src/upload-pipeline.ts +++ b/packages/core/src/upload-pipeline.ts @@ -1,5 +1,5 @@ import { computeBackoff, sha256Hex } from '@bugsee/util'; -import { asBody } from './bundle-body'; +import { asBody, isBundleBody } from './bundle-body'; import { BugseeError } from './errors'; import { type BugseeApi, @@ -174,15 +174,26 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } }; + /** The pipeline owns a body once it is handed over: release it when the upload is over, whatever the outcome. */ + const disposeBody = (bundle: Bundle): void => { + if (!isBundleBody(bundle.body)) { + return; + } + try { + void Promise.resolve(bundle.body.dispose()).catch(() => {}); + } catch { + // A body that cannot be released must never change the upload's result. + } + }; + const runOperation = async (bundle: Bundle, category: OutcomeCategory): Promise => { // BEFORE the issue: creating it is what leaves a record on the collector, so local work runs first — // and iOS carries `bundle_sha256` in the issue-create body itself, which needs the digest by then. - // Everything below still hands the transport contiguous bytes (streaming transports are a later slice), so the body - // is materialized once here: free for an in-memory body, the one copy for any other. Done BEFORE the issue is - // created, and a read that fails is an ordinary upload failure (retryable), not a throw out of the pipeline. - let bytes: Uint8Array; + // A body whose content can go missing (a temp file) is checked BEFORE the issue is created: an unreadable one is an + // ordinary retryable upload failure, not a throw out of the pipeline and not an empty issue left on the collector. + const body = asBody(bundle.body); try { - bytes = await asBody(bundle.body).bytes(); + await body.ready?.(); } catch (err) { return fail( err instanceof BugseeError @@ -192,7 +203,9 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe 'upload_failed', ); } - const checksumSha256 = await checksumOf(bytes); + // Hashed only when the body is already in memory: a file/Blob body is never pulled into memory for a digest nothing sends. + const held = body.peek(); + const checksumSha256 = held !== undefined ? await checksumOf(held) : undefined; let issue: IssueCreateResult; try { @@ -205,7 +218,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } const putOptions: PutBundleOptions = { - contentLength: bytes.length, + contentLength: body.size, fileName: bundle.fileName, ...(checksumSha256 !== undefined ? { checksumSha256 } : {}), }; @@ -222,7 +235,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } let put: PutResult; try { - put = await uploader.putBundle(endpoint, bytes, putOptions); + put = await uploader.putBundle(endpoint, bundle.body, putOptions); } catch (err) { return fail( err instanceof BugseeError @@ -279,7 +292,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe /** Begin an upload, holding a concurrency slot until it settles, then release the slot to whoever * is waiting. */ const start = (bundle: Bundle, category: OutcomeCategory): Promise => { - const operation = runOperation(bundle, category); + const operation = runOperation(bundle, category).finally(() => disposeBody(bundle)); inFlight.add(operation); void operation.finally(() => { inFlight.delete(operation); @@ -297,6 +310,7 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe if (inFlight.size >= bufferSize) { if (waiting.length >= maxWaiting) { onOutcome?.({ kind: 'drop', category, reason: 'queue_overflow' }); + disposeBody(bundle); return Promise.resolve({ ok: false, error: new BugseeError('upload queue overflow', QUEUE_OVERFLOW_CODE), diff --git a/packages/node-utils/src/file-bundle-sink.partial.test.ts b/packages/node-utils/src/file-bundle-sink.partial.test.ts new file mode 100644 index 00000000..1f6368d3 --- /dev/null +++ b/packages/node-utils/src/file-bundle-sink.partial.test.ts @@ -0,0 +1,48 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// A file handle that accepts at most 3 bytes per write, as a full disk or a pipe may: the sink must keep writing +// until the whole chunk is down, and its size must be what is really in the file. +vi.mock('node:fs/promises', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + open: async (...args: Parameters) => { + const handle = await real.open(...args); + const write = handle.write.bind(handle) as ( + b: Uint8Array, + offset: number, + length: number, + ) => Promise<{ bytesWritten: number }>; + (handle as unknown as { write: unknown }).write = ( + b: Uint8Array, + offset = 0, + length = b.length - offset, + ) => write(b, offset, Math.min(3, length)); + return handle; + }, + }; +}); + +import { createFileBundleSink } from './file-bundle-sink'; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'bugsee-sink-partial-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +describe('createFileBundleSink — short writes', () => { + it('keeps writing until the whole chunk is on disk and reports the real size', async () => { + const writer = await createFileBundleSink(dir).open(); + const chunk = Uint8Array.from({ length: 20 }, (_, i) => i); + await writer.write(chunk); + await writer.write(Uint8Array.from([100, 101])); + const body = await writer.finish(); + expect(body.size).toBe(22); + expect(Array.from(await body.bytes())).toEqual([...chunk, 100, 101]); + await body.dispose(); + }); +}); diff --git a/packages/node-utils/src/file-bundle-sink.test.ts b/packages/node-utils/src/file-bundle-sink.test.ts new file mode 100644 index 00000000..a6f6fefd --- /dev/null +++ b/packages/node-utils/src/file-bundle-sink.test.ts @@ -0,0 +1,130 @@ +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFileBundleSink, sweepStaleExports } from './file-bundle-sink'; + +let dir: string; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'bugsee-sink-')); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +const collect = async (iterable: AsyncIterable): Promise => { + const out: number[] = []; + for await (const chunk of iterable) out.push(...chunk); + return out; +}; + +describe('createFileBundleSink', () => { + it('writes chunks to a file under the directory and finishes with a file-backed body', async () => { + const writer = await createFileBundleSink(join(dir, 'export')).open(); + await writer.write(new Uint8Array([1, 2])); + await writer.write(new Uint8Array([3, 4, 5])); + const body = await writer.finish(); + expect(body.size).toBe(5); + expect(readdirSync(join(dir, 'export'))).toHaveLength(1); + expect(await collect(body.stream())).toEqual([1, 2, 3, 4, 5]); + expect(await collect(body.stream())).toEqual([1, 2, 3, 4, 5]); // restartable + expect(Array.from(await body.bytes())).toEqual([1, 2, 3, 4, 5]); + expect(body.peek()).toBeUndefined(); + await body.ready?.(); + await body.dispose(); + expect(readdirSync(join(dir, 'export'))).toHaveLength(0); + await body.dispose(); // repeatable + }); + + it('creates the directory owner-only and the file mode 0o600', async () => { + const sink = createFileBundleSink(join(dir, 'a', 'b')); + const writer = await sink.open(); + await writer.write(new Uint8Array([1])); + const body = await writer.finish(); + expect(statSync(join(dir, 'a', 'b')).mode & 0o777).toBe(0o700); + const [name] = readdirSync(join(dir, 'a', 'b')); + expect(statSync(join(dir, 'a', 'b', name as string)).mode & 0o777).toBe(0o600); + await body.dispose(); + }); + + it('abort deletes the partial file', async () => { + const writer = await createFileBundleSink(dir).open(); + await writer.write(new Uint8Array([1, 2, 3])); + await writer.abort(); + expect(readdirSync(dir)).toEqual([]); + await writer.abort(); // repeatable, never throws + }); + + it('every open() gets its own file', async () => { + const sink = createFileBundleSink(dir); + const a = await sink.open(); + const b = await sink.open(); + await a.write(new Uint8Array([1])); + await b.write(new Uint8Array([2, 2])); + const [x, y] = await Promise.all([a.finish(), b.finish()]); + expect([x.size, y.size]).toEqual([1, 2]); + expect(readdirSync(dir)).toHaveLength(2); + }); + + it('ready() rejects once the file is gone (so the upload fails before an issue is created)', async () => { + const writer = await createFileBundleSink(dir).open(); + await writer.write(new Uint8Array([1])); + const body = await writer.finish(); + for (const f of readdirSync(dir)) rmSync(join(dir, f)); + await expect(body.ready?.()).rejects.toThrow(); + }); + + it('a size of zero is a valid, empty body', async () => { + const writer = await createFileBundleSink(dir).open(); + const body = await writer.finish(); + expect(body.size).toBe(0); + expect(await collect(body.stream())).toEqual([]); + await body.dispose(); + }); +}); + +describe('sweepStaleExports', () => { + it('removes export files older than the age, keeps newer ones and unrelated files', () => { + const old = join(dir, 'export-old.zip'); + const fresh = join(dir, 'export-fresh.zip'); + const other = join(dir, 'keep.txt'); + for (const f of [old, fresh, other]) writeFileSync(f, 'x'); + const past = new Date(Date.now() - 3_600_000); + utimesSync(old, past, past); + utimesSync(other, past, past); + expect(sweepStaleExports(dir, 60_000)).toBe(1); + expect(existsSync(old)).toBe(false); + expect(existsSync(fresh)).toBe(true); + expect(existsSync(other)).toBe(true); + }); + + it('only touches names with both the export- prefix and the .zip suffix', () => { + const names = ['export-a.txt', 'other-a.zip', 'export-b.zip', 'zip']; + const past = new Date(Date.now() - 3_600_000); + for (const n of names) { + writeFileSync(join(dir, n), 'x'); + utimesSync(join(dir, n), past, past); + } + expect(sweepStaleExports(dir, 60_000)).toBe(1); + expect(readdirSync(dir).sort()).toEqual(['export-a.txt', 'other-a.zip', 'zip']); + }); + + it('keeps a file exactly as old as the limit would allow only when it is newer than the cutoff', () => { + const f = join(dir, 'export-edge.zip'); + writeFileSync(f, 'x'); + const t = new Date(Date.now() - 30_000); + utimesSync(f, t, t); + expect(sweepStaleExports(dir, 60_000)).toBe(0); + expect(sweepStaleExports(dir, 10_000)).toBe(1); + }); + + it('a missing directory is not an error', () => { + expect(sweepStaleExports(join(dir, 'nope'), 1000)).toBe(0); + }); +}); diff --git a/packages/node-utils/src/file-bundle-sink.ts b/packages/node-utils/src/file-bundle-sink.ts new file mode 100644 index 00000000..cbe15b62 --- /dev/null +++ b/packages/node-utils/src/file-bundle-sink.ts @@ -0,0 +1,114 @@ +import { createReadStream, readdirSync, rmSync, statSync } from 'node:fs'; +import { mkdir, open, readFile, stat, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { BundleBody, BundleSink, BundleSinkWriter } from '@bugsee/core'; + +// The node/electron-main sink for the streaming export (docs/design/streaming-export.md §4.5): the zip is written to a +// temp file under `dir` as it is built, and the finished body streams it from disk — a report never holds its bundle in +// the heap. The body owns the file: `dispose()` deletes it. Files are owner-only (0o600) like every other file here. + +const FILE_MODE = 0o600; +const DIR_MODE = 0o700; +const PREFIX = 'export-'; +const SUFFIX = '.zip'; + +let counter = 0; +const uniqueName = (): string => { + counter += 1; + return `${PREFIX}${Date.now().toString(36)}-${counter}-${Math.random().toString(36).slice(2, 10)}${SUFFIX}`; +}; + +function fileBody(path: string, size: number): BundleBody { + return { + size, + stream: (): AsyncIterable => createReadStream(path) as AsyncIterable, + bytes: async (): Promise => new Uint8Array(await readFile(path)), + peek: (): undefined => undefined, + ready: async (): Promise => { + await stat(path); // rejects when the file is gone + }, + dispose: async (): Promise => { + try { + await unlink(path); + } catch { + // already gone + } + }, + }; +} + +/** A sink writing each export to its own temp file under `dir` (created owner-only on first use). */ +export function createFileBundleSink(dir: string): BundleSink { + return { + async open(): Promise { + await mkdir(dir, { recursive: true, mode: DIR_MODE }); + const path = join(dir, uniqueName()); + const handle = await open(path, 'wx', FILE_MODE); + let size = 0; + let closed = false; + const close = async (): Promise => { + if (!closed) { + closed = true; + await handle.close(); + } + }; + return { + async write(chunk): Promise { + // A write may take less than it was given: continue from where it stopped, or the file is silently short. + let offset = 0; + while (offset < chunk.length) { + const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset); + offset += bytesWritten; + } + size += chunk.length; + }, + async finish(): Promise { + await close(); + return fileBody(path, size); + }, + async abort(): Promise { + try { + await close(); + } catch { + // closing a failed handle can fail again; the file is removed regardless + } + try { + await unlink(path); + } catch { + // already gone + } + }, + }; + }, + }; +} + +/** + * Delete export files in `dir` untouched for longer than `olderThanMs` (a crash left them). Returns how many went. Meant + * for a directory this process owns (the per-instance data subtree), so a live export of a sibling is never swept. + */ +export function sweepStaleExports(dir: string, olderThanMs: number): number { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return 0; // no directory, nothing to sweep + } + let removed = 0; + const cutoff = Date.now() - olderThanMs; + for (const name of names) { + if (!name.startsWith(PREFIX) || !name.endsWith(SUFFIX)) { + continue; + } + const path = join(dir, name); + try { + if (statSync(path).mtimeMs < cutoff) { + rmSync(path, { force: true }); + removed += 1; + } + } catch { + // vanished between listing and stat + } + } + return removed; +} diff --git a/packages/node-utils/src/http-request.test.ts b/packages/node-utils/src/http-request.test.ts index 2ee38cf2..1d01974b 100644 --- a/packages/node-utils/src/http-request.test.ts +++ b/packages/node-utils/src/http-request.test.ts @@ -2,6 +2,7 @@ import http from 'node:http'; import https from 'node:https'; import type { AddressInfo } from 'node:net'; import { deflateSync, gzipSync } from 'node:zlib'; +import { type BundleBody, createMemoryBody } from '@bugsee/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { httpRequest, settleOnce, transportFor } from './http-request'; @@ -196,6 +197,162 @@ describe('httpRequest', () => { // The settle-at-most-once guard, tested directly. Through a real socket its second-failure path is only // reachable in a race — a timeout that errors BOTH the request and the in-flight response — so whether a // run covered it depended on timing, and CI's coverage gate failed on unchanged code (99.81% lines). +describe('httpRequest — BundleBody', () => { + const bodyOf = (chunks: Uint8Array[], over: Partial = {}): BundleBody => ({ + ...createMemoryBody(chunks), + ...over, + }); + + it('declares that it can send a BundleBody', () => { + expect(httpRequest.acceptsBundleBody).toBe(true); + }); + + it('streams a body to the server chunk by chunk with the declared Content-Length and never materializes it', async () => { + const url = await listen(); + let received: Buffer = Buffer.alloc(0); + let length: string | undefined; + setHandler(async (req, res) => { + length = req.headers['content-length']; + received = await readBody(req); + res.statusCode = 200; + res.end('ok'); + }); + const chunks = Array.from({ length: 50 }, (_, i) => new Uint8Array(40_000).fill(i)); + const bytes = vi.fn(async () => new Uint8Array()); + const peek = vi.fn(() => undefined); + const body = bodyOf(chunks, { bytes, peek }); + const res = await httpRequest(url, { + method: 'PUT', + headers: { 'Content-Length': String(body.size) }, + body, + }); + expect(res.status).toBe(200); + expect(length).toBe(String(50 * 40_000)); + expect(received.length).toBe(50 * 40_000); + expect(received[0]).toBe(0); + expect(received[received.length - 1]).toBe(49); + expect(bytes).not.toHaveBeenCalled(); + }); + + it('can send the same body again (a retry reads it from the start)', async () => { + const url = await listen(); + const sizes: number[] = []; + setHandler(async (req, res) => { + sizes.push((await readBody(req)).length); + res.end(); + }); + const body = bodyOf([new Uint8Array([1, 2, 3]), new Uint8Array([4])]); + await httpRequest(url, { method: 'PUT', body }); + await httpRequest(url, { method: 'PUT', body }); + expect(sizes).toEqual([4, 4]); + }); + + it('rejects, and stops, when the body stream fails part-way', async () => { + const url = await listen(); + setHandler((req, res) => { + void readBody(req) + .catch(() => {}) + .finally(() => res.end()); + }); + const body = bodyOf([new Uint8Array([1])], { + stream: () => ({ + async *[Symbol.asyncIterator]() { + yield new Uint8Array([1]); + throw new Error('temp file vanished'); + }, + }), + }); + await expect(httpRequest(url, { method: 'PUT', body })).rejects.toThrow('temp file vanished'); + }); + + it('leaves no listeners behind after the socket stalls and drains repeatedly', async () => { + const url = await listen(); + setHandler(async (req, res) => { + for await (const chunk of req) { + void chunk; + await new Promise((r) => setTimeout(r, 1)); // a slow reader makes the writer stall + } + res.end(); + }); + const created: http.ClientRequest[] = []; + const realRequest = http.request; + const spy = vi.spyOn(http, 'request').mockImplementation(((...args: unknown[]) => { + const req = (realRequest as (...a: unknown[]) => http.ClientRequest)(...args); + created.push(req); + return req; + }) as typeof http.request); + try { + const chunks = Array.from({ length: 400 }, () => new Uint8Array(200_000)); + await httpRequest(url, { method: 'PUT', body: bodyOf(chunks) }); + } finally { + spy.mockRestore(); + } + expect(created).toHaveLength(1); + expect(created[0]?.listenerCount('drain')).toBe(0); + expect(created[0]?.listenerCount('close')).toBeLessThanOrEqual(1); + }); + + it('stops reading the body (closing its stream) once the request has failed', async () => { + const url = await listen(); + setHandler((req) => { + req.socket.destroy(); + }); + let closed = false; + const body = bodyOf([new Uint8Array([1])], { + stream: () => ({ + async *[Symbol.asyncIterator]() { + try { + for (let i = 0; i < 100_000; i++) yield new Uint8Array(100_000); + } finally { + closed = true; + } + }, + }), + }); + await expect(httpRequest(url, { method: 'PUT', body })).rejects.toThrow(); + await vi.waitFor(() => expect(closed).toBe(true)); + }); + + it('aborts the connection when the body stream fails, so the server sees an incomplete request', async () => { + const url = await listen(); + let aborted = false; + let firstByte!: () => void; + const seenFirstByte = new Promise((resolve) => (firstByte = resolve)); + setHandler((req) => { + let got = 0; + req.on('data', (c: Buffer) => { + got += c.length; + firstByte(); + }); + req.socket.once('close', () => { + aborted = got < 99; + }); + }); + const body = bodyOf([new Uint8Array([1])], { + stream: () => ({ + async *[Symbol.asyncIterator]() { + yield new Uint8Array([1]); + await seenFirstByte; // the connection is up and carrying the body + throw new Error('boom'); + }, + }), + }); + await expect( + httpRequest(url, { method: 'PUT', headers: { 'Content-Length': '99' }, body }), + ).rejects.toThrow('boom'); + await vi.waitFor(() => expect(aborted).toBe(true)); + }); + + it('rejects when the server closes the connection while a large body is still being written', async () => { + const url = await listen(); + setHandler((req) => { + req.socket.destroy(); + }); + const chunks = Array.from({ length: 200 }, () => new Uint8Array(100_000)); + await expect(httpRequest(url, { method: 'PUT', body: bodyOf(chunks) })).rejects.toThrow(); + }); +}); + describe('settleOnce', () => { it('rejects with the FIRST failure and ignores every later one', () => { const resolve = vi.fn(); diff --git a/packages/node-utils/src/http-request.ts b/packages/node-utils/src/http-request.ts index c6a81573..8a9eaae3 100644 --- a/packages/node-utils/src/http-request.ts +++ b/packages/node-utils/src/http-request.ts @@ -1,7 +1,13 @@ import http from 'node:http'; import https from 'node:https'; import { gunzipSync, inflateSync } from 'node:zlib'; -import type { HttpRequestOptions, HttpResponse, HttpTransport } from '@bugsee/core'; +import { + type BundleBody, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, + isBundleBody, +} from '@bugsee/core'; // The Node implementation of core's HttpTransport primitive — the spine for core's // BugseeApi + BundleUploader. Uses node:http(s) rather than global fetch so proxy agents can be @@ -54,7 +60,7 @@ export function settleOnce( }; } -export const httpRequest: HttpTransport = (url: string, options: HttpRequestOptions = {}) => { +const httpRequestImpl = (url: string, options: HttpRequestOptions = {}): Promise => { const { method = 'GET', headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options; const parsed = new URL(url); const transport = transportFor(parsed.protocol); @@ -92,9 +98,43 @@ export const httpRequest: HttpTransport = (url: string, options: HttpRequestOpti req.destroy(new Error(`request to ${url} timed out after ${timeoutMs}ms`)); }); - if (body !== undefined) { + if (body === undefined) { + req.end(); + } else if (typeof body !== 'string' && isBundleBody(body)) { + // Pipe the body: one chunk at a time, waiting for the socket to drain, so a large bundle is never held whole. + void pipeBody(req, body).catch((error: unknown) => { + fail(error as Error); + req.destroy(); + }); + } else { req.write(body); + req.end(); } - req.end(); }); }; + +/** Write a body's chunks to the request with backpressure, then end it. Stops quietly if the request closes first. */ +async function pipeBody(req: http.ClientRequest, body: BundleBody): Promise { + for await (const chunk of body.stream()) { + if (req.destroyed) { + return; + } + if (!req.write(chunk)) { + await new Promise((resolve) => { + const done = (): void => { + req.off('drain', done); + req.off('close', done); // whichever fired, the other listener must not stay behind (one per stall adds up) + resolve(); + }; + req.on('drain', done); + req.on('close', done); + }); + } + } + req.end(); +} + +/** The node:http(s) transport. It also sends a `BundleBody` as a request body without materializing it. */ +export const httpRequest: HttpTransport = Object.assign(httpRequestImpl, { + acceptsBundleBody: true as const, +}); diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index e52b5d75..ac9ddadf 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -16,6 +16,7 @@ export { type CrashpadSessionMarkerStore, createNodeCrashpadSessionMarkerStore, } from './crashpad-session-marker-store'; +export { createFileBundleSink, sweepStaleExports } from './file-bundle-sink'; export { createFsChunkStorage } from './fs-chunk-storage'; export { appendFileSecure, diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index aa8d3b08..2630e8e0 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -49,8 +49,6 @@ import { getOrCreateInterceptor, getProcessStatus, getServiceManifests, - type HttpRequestOptions, - type HttpResponse, type HttpTransport, LifecycleEvents, type LifecycleEventsListener, @@ -70,6 +68,7 @@ import { setCarrierStatus, setStackAppRoot, TransportToken, + withTransportHeaders, } from '@bugsee/core'; import { createBatchedFsChunkStorage, @@ -582,13 +581,8 @@ export interface LaunchResult { // Wrap the transport so EVERY SDK request (control-plane sessions/issues AND the signed S3 PUT) // carries X-Bugsee-Internal — the network capture's default self-isolation skips it, so the SDK // never records its own traffic. An extra unsigned header is harmless to the S3 signature. -const internalTagged = - (transport: HttpTransport): HttpTransport => - (url: string, options: HttpRequestOptions = {}): Promise => - transport(url, { - ...options, - headers: { ...options.headers, 'x-bugsee-internal': '1' }, - }); +const internalTagged = (transport: HttpTransport): HttpTransport => + withTransportHeaders(transport, { 'x-bugsee-internal': '1' }); /** `process.cwd()` can throw when the working directory has been deleted out from under the process. */ const safeCwd = (): string | undefined => { diff --git a/packages/vercel-edge/src/launch.ts b/packages/vercel-edge/src/launch.ts index a23c02d5..e12f14b6 100644 --- a/packages/vercel-edge/src/launch.ts +++ b/packages/vercel-edge/src/launch.ts @@ -27,8 +27,6 @@ import { getCarrierStatus, getOrCreateInterceptor, getServiceManifests, - type HttpRequestOptions, - type HttpResponse, type HttpTransport, type LifecycleEventsListener, type RequestContext, @@ -42,6 +40,7 @@ import { setCarrierLastLaunch, setCarrierStatus, TransportToken, + withTransportHeaders, } from '@bugsee/core'; import { BugseeOption, type RuntimeType } from '@bugsee/protocol'; import { describeValue, reportGuarded } from '@bugsee/util'; @@ -212,10 +211,8 @@ export interface BugseeEdgeLaunchOptions { export type Bugsee = BugseeClient; // Tag every SDK request with X-Bugsee-Internal so the network capture self-skips the SDK's own traffic. -const internalTagged = - (transport: HttpTransport): HttpTransport => - (url: string, options: HttpRequestOptions = {}): Promise => - transport(url, { ...options, headers: { ...options.headers, 'x-bugsee-internal': '1' } }); +const internalTagged = (transport: HttpTransport): HttpTransport => + withTransportHeaders(transport, { 'x-bugsee-internal': '1' }); /** Launch the edge SDK. A per-isolate singleton (a repeat call is ignored). Returns the started client. */ export function launchEdge(appToken: string, options: BugseeEdgeLaunchOptions = {}): Bugsee { diff --git a/packages/webworker/src/launch.ts b/packages/webworker/src/launch.ts index 22e0a91b..997cb857 100644 --- a/packages/webworker/src/launch.ts +++ b/packages/webworker/src/launch.ts @@ -44,8 +44,6 @@ import { getCarrierStatus, getOrCreateInterceptor, getServiceManifests, - type HttpRequestOptions, - type HttpResponse, type HttpTransport, LifecycleEvents, type LifecycleEventsListener, @@ -60,6 +58,7 @@ import { setCarrierLastLaunch, setCarrierStatus, TransportToken, + withTransportHeaders, } from '@bugsee/core'; import { BugseeOption } from '@bugsee/protocol'; import { isServiceWorker } from '@bugsee/util'; @@ -210,10 +209,8 @@ export interface BugseeWorkerLaunchOptions { export type Bugsee = BugseeClient; // Tag every SDK request with X-Bugsee-Internal so the network capture self-skips the SDK's own traffic. -const internalTagged = - (transport: HttpTransport): HttpTransport => - (url: string, options: HttpRequestOptions = {}): Promise => - transport(url, { ...options, headers: { ...options.headers, 'x-bugsee-internal': '1' } }); +const internalTagged = (transport: HttpTransport): HttpTransport => + withTransportHeaders(transport, { 'x-bugsee-internal': '1' }); /** Launch the Bugsee worker SDK. A per-worker singleton (a repeat call is ignored). Returns the started client. */ export function launch(appToken: string, options: BugseeWorkerLaunchOptions = {}): Bugsee { From 83f8d38e1a0b79102a97f44f5162d732c9a8367a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 00:13:11 +0500 Subject: [PATCH 09/33] feat(core,node-utils): streamed durable staging of file-backed bundles (streaming export S5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BundleStore.putFrame (node: temp file + fsync + rename), retainBody shared between staging and the upload, a staging that lands after the upload settled frees its own blob, flush waits for streamed staging; stores without putFrame get the materialized frame through put. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- packages/core/src/bundle-body.test.ts | 62 +++++ packages/core/src/bundle-body.ts | 23 ++ .../core/src/durable-upload-pipeline.test.ts | 243 +++++++++++++++++- packages/core/src/durable-upload-pipeline.ts | 141 ++++++++-- packages/core/src/index.ts | 1 + packages/node-utils/src/bundle-store.test.ts | 72 ++++++ packages/node-utils/src/bundle-store.ts | 31 ++- packages/node-utils/src/file-bundle-sink.ts | 8 +- packages/node-utils/src/write-fully.ts | 10 + 10 files changed, 558 insertions(+), 35 deletions(-) create mode 100644 packages/node-utils/src/write-fully.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index ba4a8d5d..801bb798 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -215,7 +215,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | -| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b** durable store streaming `putFrame`. **S5c** client wiring. | | +| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c** client wiring. | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | diff --git a/packages/core/src/bundle-body.test.ts b/packages/core/src/bundle-body.test.ts index 48f2e3c7..c0deba20 100644 --- a/packages/core/src/bundle-body.test.ts +++ b/packages/core/src/bundle-body.test.ts @@ -98,3 +98,65 @@ describe('asBody / isBundleBody', () => { expect(isBundleBody(u8(1))).toBe(false); }); }); + +import { retainBody } from './bundle-body'; + +describe('retainBody', () => { + const tracked = () => { + const base = createMemoryBody([new Uint8Array([1, 2, 3])]); + let disposed = 0; + return { + body: { + ...base, + dispose: () => { + disposed += 1; + }, + ready: async () => {}, + }, + disposed: () => disposed, + }; + }; + + it('disposes the underlying body only after every owner has released it', () => { + const { body, disposed } = tracked(); + const shared = retainBody(body, 3); + shared.dispose(); + shared.dispose(); + expect(disposed()).toBe(0); + shared.dispose(); + expect(disposed()).toBe(1); + }); + + it('ignores releases beyond the count (a body is never disposed twice)', () => { + const { body, disposed } = tracked(); + const shared = retainBody(body, 1); + shared.dispose(); + shared.dispose(); + shared.dispose(); + expect(disposed()).toBe(1); + }); + + it('is the same content: size, stream, bytes, peek and ready all delegate', async () => { + const { body } = tracked(); + const shared = retainBody(body, 2); + expect(shared.size).toBe(3); + expect(Array.from(await shared.bytes())).toEqual([1, 2, 3]); + expect(shared.peek()).toBe(body.peek()); + const streamed: number[] = []; + for await (const chunk of shared.stream()) streamed.push(...chunk); + expect(streamed).toEqual([1, 2, 3]); + await expect(shared.ready?.()).resolves.toBeUndefined(); + }); + + it('has no ready when the underlying body has none', () => { + const shared = retainBody(createMemoryBody([]), 1); + expect(shared.ready).toBeUndefined(); + }); + + it('returns the dispose result of the final release (a promise is passed on)', async () => { + const base = createMemoryBody([]); + const body = { ...base, dispose: () => Promise.resolve() }; + const shared = retainBody(body, 1); + await expect(shared.dispose()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/src/bundle-body.ts b/packages/core/src/bundle-body.ts index ec72033c..fa95649a 100644 --- a/packages/core/src/bundle-body.ts +++ b/packages/core/src/bundle-body.ts @@ -76,3 +76,26 @@ export function createMemoryBody(input: readonly Uint8Array[]): BundleBody { export function asBody(body: BundleBodyLike): BundleBody { return isBundleBody(body) ? body : createMemoryBody([body]); } + +/** + * Share one body between `count` owners (staging it durably and uploading it read it at the same time): the underlying + * body is disposed when the LAST of them releases it, and never twice. Content access delegates untouched. + */ +export function retainBody(body: BundleBody, count: number): BundleBody { + let remaining = count; + const shared: BundleBody = { + size: body.size, + stream: () => body.stream(), + bytes: () => body.bytes(), + peek: () => body.peek(), + dispose: (): void | Promise => { + remaining -= 1; + return remaining === 0 ? body.dispose() : undefined; // exactly the last claim disposes; extra releases fall past 0 + }, + }; + if (body.ready !== undefined) { + const ready = body.ready.bind(body); + shared.ready = ready; + } + return shared; +} diff --git a/packages/core/src/durable-upload-pipeline.test.ts b/packages/core/src/durable-upload-pipeline.test.ts index 13fc56c5..7945e158 100644 --- a/packages/core/src/durable-upload-pipeline.test.ts +++ b/packages/core/src/durable-upload-pipeline.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_DURABLE_RETENTION, deserializeBundle, deserializeBundleFrame, + frameHeader, type IdentifiedBundle, serializeBundle, } from './durable-upload-pipeline'; @@ -67,12 +68,252 @@ describe('serializeBundle with a BundleBody', () => { ); }); - it('refuses a body that is not held in memory (a streaming put is a later slice) instead of writing a short frame', () => { + it('refuses a body that is not held in memory instead of writing a short frame (staging streams or materializes it)', () => { const lazy = { ...createMemoryBody([new Uint8Array([1])]), peek: () => undefined }; expect(() => serializeBundle({ ...bundle(), body: lazy }, 5)).toThrow(/not held in memory/); }); }); +describe('frameHeader', () => { + it('is the frame prefix: serializeBundle is exactly header followed by the body bytes', () => { + const b = { ...bundle(), reportId: 'inc-1' }; + const header = frameHeader(b, 77); + const framed = serializeBundle(b, 77); + expect(Array.from(framed.subarray(0, header.length))).toEqual(Array.from(header)); + expect(Array.from(framed.subarray(header.length))).toEqual(Array.from(b.body as Uint8Array)); + expect(new DataView(header.buffer, header.byteOffset).getUint32(0, true)).toBe( + header.length - 4, + ); + }); +}); + +describe('createDurableUploadPipeline — bodies that are not held in memory', () => { + const lazyBody = (bytes: number[], over: Record = {}) => { + const base = createMemoryBody([new Uint8Array(bytes)]); + let disposed = 0; + const body = { + ...base, + peek: () => undefined, + dispose: () => { + disposed += 1; + }, + ...over, + }; + return { body, disposed: () => disposed }; + }; + const retryable: UploadResult = { ok: false, error: new BugseeError('offline', 0) }; + + it('without a streaming store: stages the materialized frame, byte-identical to the in-memory framing', async () => { + const { store, map } = memStore(); + const { pipeline } = fakePipeline(retryable); + const { body } = lazyBody([9, 8, 7]); + const durable = createDurableUploadPipeline({ + store, + pipeline, + newId: () => 'a', + now: () => 5, + }); + const result = await durable.enqueue({ ...bundle(), body }); + expect(Array.from(map.get('a') as Uint8Array)).toEqual( + Array.from(serializeBundle({ ...bundle(), body: new Uint8Array([9, 8, 7]) }, 5)), + ); + expect(result.retained).toBe(true); + }); + + it('with a streaming store: hands it the frame header and the body, and never materializes the body', async () => { + const { store, map } = memStore(); + const calls: Array<{ id: string; header: Uint8Array; body: unknown }> = []; + const streaming: BundleStore = { + ...store, + putFrame: async (id, header, b) => { + calls.push({ id, header, body: b }); + }, + }; + const { pipeline } = fakePipeline(retryable); + const bytes = vi.fn(async () => new Uint8Array([0])); + const { body } = lazyBody([1, 2, 3, 4], { bytes }); + const durable = createDurableUploadPipeline({ + store: streaming, + pipeline, + newId: () => 'a', + now: () => 5, + }); + const result = await durable.enqueue({ ...bundle(), body }); + expect(calls).toHaveLength(1); + expect(calls[0]?.id).toBe('a'); + expect(Array.from(calls[0]?.header as Uint8Array)).toEqual( + Array.from(frameHeader(bundle(), 5)), + ); + expect(map.size).toBe(0); // put() was not used + expect(bytes).not.toHaveBeenCalled(); + expect(result.retained).toBe(true); + }); + + it('a streaming store that rejects is reported, the bundle is not retained, and the upload still goes out', async () => { + const { store } = memStore(); + const failing: BundleStore = { + ...store, + putFrame: () => Promise.reject(new Error('disk full')), + }; + const { pipeline, enqueue } = fakePipeline(retryable); + const errors: unknown[] = []; + const { body } = lazyBody([1]); + const durable = createDurableUploadPipeline({ + store: failing, + pipeline, + onError: (e) => errors.push(e), + }); + const result = await durable.enqueue({ ...bundle(), body }); + expect((errors[0] as Error).message).toBe('disk full'); + expect(result.retained).toBeUndefined(); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('a body that cannot be read for staging is reported, not retained, and the upload still goes out', async () => { + const { store } = memStore(); + const { pipeline, enqueue } = fakePipeline(retryable); + const errors: unknown[] = []; + const { body } = lazyBody([1], { bytes: () => Promise.reject(new Error('temp gone')) }); + const durable = createDurableUploadPipeline({ + store, + pipeline, + onError: (e) => errors.push(e), + }); + const result = await durable.enqueue({ ...bundle(), body }); + expect((errors[0] as Error).message).toBe('temp gone'); + expect(result.retained).toBeUndefined(); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('the body outlives whichever finishes last, staging or upload, and is disposed exactly once', async () => { + for (const order of ['upload-first', 'staging-first'] as const) { + const { store } = memStore(); + let releaseStaging!: () => void; + const stagingGate = new Promise((resolve) => (releaseStaging = resolve)); + let releaseUpload!: () => void; + const uploadGate = new Promise((resolve) => (releaseUpload = resolve)); + const streaming: BundleStore = { ...store, putFrame: () => stagingGate }; + const pipeline: UploadPipeline = { + // like the real pipeline: owns the body it is handed and disposes it when the upload is over + enqueue: async (b) => { + await uploadGate; + await (b.body as { dispose(): void }).dispose(); + return { ok: true }; + }, + flush: async () => true, + drop: () => {}, + }; + const { body, disposed } = lazyBody([1, 2]); + const durable = createDurableUploadPipeline({ store: streaming, pipeline }); + const pending = durable.enqueue({ ...bundle(), body }); + const first = order === 'upload-first' ? releaseUpload : releaseStaging; + const second = order === 'upload-first' ? releaseStaging : releaseUpload; + first(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(disposed()).toBe(0); // the other owner still needs it + second(); + await pending; + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(disposed()).toBe(1); + } + }); + + it('an upload that settles BEFORE a slow streamed staging finishes leaves no blob behind afterwards', async () => { + const { store, map } = memStore(); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const streaming: BundleStore = { + ...store, + putFrame: async (id, header) => { + await gate; + map.set(id, header); // the blob is only published when the write completes + }, + }; + const { pipeline } = fakePipeline({ ok: true }); + const { body } = lazyBody([1, 2]); + const durable = createDurableUploadPipeline({ store: streaming, pipeline, newId: () => 'a' }); + const done = durable.enqueue({ ...bundle(), body }); + await done; // settled: delivered + expect(map.has('a')).toBe(false); + release(); + for (let i = 0; i < 30; i++) await Promise.resolve(); + expect(map.has('a')).toBe(false); // the late publish was cleaned up, not left for the next launch to re-upload + }); + + it('flush also waits for streamed staging to finish, so a flush-then-exit cannot lose the blob', async () => { + const { store } = memStore(); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const streaming: BundleStore = { ...store, putFrame: () => gate }; + const { pipeline } = fakePipeline(retryable); + const { body } = lazyBody([1]); + const durable = createDurableUploadPipeline({ store: streaming, pipeline }); + void durable.enqueue({ ...bundle(), body }); + let flushed = false; + const flushing = durable.flush().then((v) => { + flushed = true; + return v; + }); + // a real macrotask turn, so a flush that only LOOKS at staging (a timer, not the write) would already have resolved + await new Promise((resolve) => + (globalThis as unknown as { setTimeout: (f: () => void, ms: number) => unknown }).setTimeout( + resolve, + 20, + ), + ); + expect(flushed).toBe(false); + release(); + expect(await flushing).toBe(true); + }); + + it('flush with a timeout gives up on a staging that never finishes and says so', async () => { + const { store } = memStore(); + const streaming: BundleStore = { ...store, putFrame: () => new Promise(() => {}) }; + const { pipeline } = fakePipeline(retryable); + const { body } = lazyBody([1]); + const durable = createDurableUploadPipeline({ + store: streaming, + pipeline, + sleep: () => Promise.resolve(), + }); + void durable.enqueue({ ...bundle(), body }); + expect(await durable.flush(10)).toBe(false); + }); + + it('once a streamed staging has finished, flush is the wrapped flush again (nothing left to wait for)', async () => { + const { store } = memStore(); + const streaming: BundleStore = { ...store, putFrame: async () => {} }; + const { pipeline, flush } = fakePipeline(retryable); + const sleep = vi.fn(async () => {}); + const { body } = lazyBody([1]); + const durable = createDurableUploadPipeline({ store: streaming, pipeline, sleep }); + await durable.enqueue({ ...bundle(), body }); + for (let i = 0; i < 30; i++) await Promise.resolve(); + sleep.mockClear(); + expect(await durable.flush(7)).toBe(true); + expect(flush).toHaveBeenCalledWith(7); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('flush without streamed staging is exactly the wrapped pipeline flush', async () => { + const { store } = memStore(); + const { pipeline, flush } = fakePipeline(); + const durable = createDurableUploadPipeline({ store, pipeline }); + expect(await durable.flush(5)).toBe(true); + expect(flush).toHaveBeenCalledWith(5); + }); + + it('an in-memory body keeps the synchronous put and is handed to the pipeline as it was', async () => { + const { store, map } = memStore(); + const { pipeline, enqueue } = fakePipeline({ ok: true }); + const durable = createDurableUploadPipeline({ store, pipeline, newId: () => 'a' }); + const b = bundle(); + await durable.enqueue(b); + expect(map.has('a')).toBe(false); // settled upload frees it + expect(enqueue.mock.calls[0]?.[0]).toBe(b); + }); +}); + describe('deserializeBundleFrame + DEFAULT_DURABLE_RETENTION', () => { // Both exist for ONE reason: the browser/worker dead-sibling recovery leg reads a dead instance's blobs // straight out of the shared store, so it never went through `recover()` and never saw the retention diff --git a/packages/core/src/durable-upload-pipeline.ts b/packages/core/src/durable-upload-pipeline.ts index 04b540db..7b92b764 100644 --- a/packages/core/src/durable-upload-pipeline.ts +++ b/packages/core/src/durable-upload-pipeline.ts @@ -1,7 +1,7 @@ import type { RequestJson } from '@bugsee/protocol'; import { serviceToken } from '@bugsee/service'; import { isThenable, strFromU8, strToU8 } from '@bugsee/util'; -import { asBody } from './bundle-body'; +import { asBody, type BundleBody, isBundleBody, retainBody } from './bundle-body'; import { type Bundle, isUploadSettled, @@ -67,6 +67,13 @@ export interface BundleStore { * one — must reject, or the incident is lost. A synchronous store simply returns nothing and throws. */ put(id: string, bytes: Uint8Array): void | Promise; + /** + * Optional: durably write a bundle whose body is NOT held in memory (a temp file, a Blob) as the frame + * `header ++ body`, reading the body as a stream so it is never materialized. Same contract as `put` (resolves once + * durable, rejects on failure, replaces an existing blob, never leaves a torn blob behind). A store without it is handed + * the whole frame through `put` instead (which materializes the body once). + */ + putFrame?(id: string, header: Uint8Array, body: BundleBody): Promise; /** Ids of every bundle still pending (written and not yet removed). */ list(): string[]; /** Read a bundle blob, or undefined if it is absent. */ @@ -174,8 +181,10 @@ export const DEFAULT_DURABLE_RETENTION: Required = { // Durable frame: [4-byte LE header length][header JSON (utf8)][bundle body bytes]. The header carries // the request.json + fileName so the full Bundle can be reconstructed for re-upload from the blob. -export function serializeBundle(bundle: IdentifiedBundle, firstSeenMs?: number): Uint8Array { - const header = strToU8( + +/** The frame prefix `[4-byte LE length][header JSON]`: everything but the body bytes. */ +export function frameHeader(bundle: IdentifiedBundle, firstSeenMs?: number): Uint8Array { + const json = strToU8( JSON.stringify({ request: bundle.request, fileName: bundle.fileName, @@ -188,17 +197,24 @@ export function serializeBundle(bundle: IdentifiedBundle, firstSeenMs?: number): ...(bundle.reportId !== undefined ? { reportId: bundle.reportId } : {}), }), ); - // A synchronous writer needs the bytes now, so only a body held in memory can be framed here; a file/Blob-backed body - // needs the streaming `put` (a later slice) and must not be silently framed short. `enqueue` catches this and reports it - // (the upload still goes out, just not retained), which is the existing best-effort contract for a failed stage. + const out = new Uint8Array(4 + json.length); + new DataView(out.buffer).setUint32(0, json.length, true); + out.set(json, 4); + return out; +} + +export function serializeBundle(bundle: IdentifiedBundle, firstSeenMs?: number): Uint8Array { + const header = frameHeader(bundle, firstSeenMs); + // A synchronous writer needs the bytes now, so only a body held in memory can be framed here; any other body is staged + // by `enqueue` through the streaming `putFrame` (or materialized once for a store without it), and must never be + // framed short. const bytes = asBody(bundle.body).peek(); if (bytes === undefined) { throw new Error('Bundle body is not held in memory: cannot frame it synchronously'); } - const out = new Uint8Array(4 + header.length + bytes.length); - new DataView(out.buffer).setUint32(0, header.length, true); - out.set(header, 4); - out.set(bytes, 4 + header.length); + const out = new Uint8Array(header.length + bytes.length); + out.set(header, 0); + out.set(bytes, header.length); return out; } @@ -294,6 +310,11 @@ export function createDurableUploadPipeline( // launch, and retrying it again immediately would only spin. const attempted = new Set(); + // Streamed stagings still writing, by id (each promise never rejects). `flush` waits for all of them. + const stagings = new Map>(); + // Ids whose upload settled while their streamed staging was still writing: the staging deletes them on landing. + const settledWhileStaging = new Set(); + // Staged ids a SELECTIVE recover() pass is currently HOLDING BACK for a later one (see // DurableRecoverOptions.select). Consulted ONLY by the pump, which runs on every completion and would // otherwise hand a later instance's blob over unreconciled mid-scan. @@ -335,21 +356,30 @@ export function createDurableUploadPipeline( attempted.delete(id); return result; } - if (isUploadSettled(result)) { - removeSafe(id); // delivered, or refused — either way there is nothing left to retry - } - // This upload held a slot and has now released it: hand the next staged bundle over. - // - // Two things bound this loop, and both are load-bearing. Pumping ONLY here means no attempt can - // happen without an upload having completed first; and `attempted` means each staged bundle is - // handed over at most once per completion. Remove either and the pump becomes a synchronous - // microtask spin that starves the event loop rather than failing an assertion — which is how - // the mutation check for both of them shows up. - pump(); - return result; + return finish(id, result); }); }; + const finish = (id: string, result: UploadResult): UploadResult => { + if (isUploadSettled(result)) { + removeSafe(id); // delivered, or refused — either way there is nothing left to retry + if (stagings.has(id)) { + // A streamed staging is still writing this blob and would publish it AFTER this removal; it frees its own blob + // when it lands (see `enqueue`), or the next launch would re-upload a report that was already delivered. + settledWhileStaging.add(id); + } + } + // This upload held a slot and has now released it: hand the next staged bundle over. + // + // Two things bound this loop, and both are load-bearing. Pumping ONLY here means no attempt can + // happen without an upload having completed first; and `attempted` means each staged bundle is + // handed over at most once per completion. Remove either and the pump becomes a synchronous + // microtask spin that starves the event loop rather than failing an assertion — which is how + // the mutation check for both of them shows up. + pump(); + return result; + }; + /** * Hand ONE staged-but-not-yet-attempted bundle to the pipeline, after every completion. * @@ -475,6 +505,36 @@ export function createDurableUploadPipeline( } }; + /** + * Stage a body that is not in memory. A store with `putFrame` streams it; any other gets the materialized frame through + * `put`. Either way the staging claim on the shared body is released when the write settles (or never started). + */ + const stageStreamed = async ( + id: string, + bundle: IdentifiedBundle, + shared: BundleBody, + firstSeenMs: number, + ): Promise => { + try { + const header = frameHeader(bundle, firstSeenMs); + if (store.putFrame !== undefined) { + await store.putFrame(id, header, shared); + return; + } + const body = await shared.bytes(); + const frame = new Uint8Array(header.length + body.length); + frame.set(header, 0); + frame.set(body, header.length); + await store.put(id, frame); + } finally { + try { + await shared.dispose(); // this claim only; the upload holds the other + } catch { + // releasing must never change the staging verdict + } + } + }; + return { enqueue(bundle: Bundle, hint?: UploadHint): Promise { const id = newId(); @@ -484,11 +544,31 @@ export function createDurableUploadPipeline( // See UploadResult.retained. // `true`/`false` once known; a promise while an asynchronous store is still writing. let staged: boolean | Promise = false; + // A body that is not held in memory is shared between staging and the upload (both read it, at the same time): + // each releases its own claim, and the underlying body (a temp file) goes away when the LAST one does. + const streamed = isBundleBody(bundle.body) && bundle.body.peek() === undefined; + const shared = streamed ? retainBody(bundle.body as BundleBody, 2) : undefined; try { - const written = store.put(id, serializeBundle(bundle, now())); // durable BEFORE the attempt + const written = + shared !== undefined + ? stageStreamed(id, bundle, shared, now()) // durable BEFORE the attempt is confirmed + : store.put(id, serializeBundle(bundle, now())); // durable BEFORE the attempt // `Promise.resolve` normalizes a hand-rolled thenable whose `then` returns something other than // a promise — otherwise `staged` lands on neither branch below and a working store is silently // reported as unstaged forever. + if (shared !== undefined) { + const writing = Promise.resolve(written).then( + () => {}, + () => {}, // reported below; this promise only tracks completion + ); + stagings.set(id, writing); + void writing.then(() => { + stagings.delete(id); + if (settledWhileStaging.delete(id)) { + removeSafe(id); // the upload already settled: this late blob has nothing left to retry + } + }); + } staged = isThenable(written) ? Promise.resolve(written).then( () => true, @@ -502,7 +582,7 @@ export function createDurableUploadPipeline( report(error); // best-effort persistence must never block the upload } // The attempt does NOT wait on the durable write; only the `retained` VERDICT does. - const result = attempt(id, bundle, hint); + const result = attempt(id, shared !== undefined ? { ...bundle, body: shared } : bundle, hint); if (staged === false) { return result; } @@ -534,8 +614,17 @@ export function createDurableUploadPipeline( } }, - flush(timeout?: number): Promise { - return pipeline.flush(timeout); + async flush(timeout?: number): Promise { + if (stagings.size === 0) { + return pipeline.flush(timeout); + } + // A streamed blob is only durable once its write is done: a flush-then-exit must wait for it as well. + const staging = Promise.all([...stagings.values()]).then(() => true); + const [uploaded, written] = await Promise.all([ + pipeline.flush(timeout), + timeout === undefined ? staging : Promise.race([staging, sleep(timeout).then(() => false)]), + ]); + return uploaded && written; }, drop(reason: string, category: OutcomeCategory): void { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21136a65..caa205bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,6 +210,7 @@ export { type DurableUploadPipelineOptions, deserializeBundle, deserializeBundleFrame, + frameHeader, type IdentifiedBundle, serializeBundle, } from './durable-upload-pipeline'; diff --git a/packages/node-utils/src/bundle-store.test.ts b/packages/node-utils/src/bundle-store.test.ts index 9a48c013..c8f7e3eb 100644 --- a/packages/node-utils/src/bundle-store.test.ts +++ b/packages/node-utils/src/bundle-store.test.ts @@ -91,4 +91,76 @@ describe('createNodeBundleStore — a staged bundle is never half-written', () = expect(store.list()).toEqual([]); expect(readdirSync(dir)).toContain('b2.bundle.999.tmp'); // it is there, just not offered to recovery }); + + describe('putFrame (streamed body)', () => { + const body = (chunks: number[][], over: Record = {}) => ({ + size: chunks.reduce((n, c) => n + c.length, 0), + stream: () => ({ + async *[Symbol.asyncIterator]() { + for (const c of chunks) yield new Uint8Array(c); + }, + }), + bytes: async () => new Uint8Array(), + peek: () => undefined, + dispose: () => {}, + ...over, + }); + + it('writes header then every body chunk as one frame, readable with read()', async () => { + const store = createNodeBundleStore(dir); + await store.putFrame?.('f', new Uint8Array([1, 2]), body([[3, 4], [5], [6, 7]])); + expect([...(store.read('f') ?? [])]).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(store.list()).toEqual(['f']); + }); + + it('replaces an existing blob of that id, and the file is owner-only', async () => { + const store = createNodeBundleStore(dir); + store.put('f', new Uint8Array([9, 9, 9, 9])); + await store.putFrame?.('f', new Uint8Array([1]), body([[2]])); + expect([...(store.read('f') ?? [])]).toEqual([1, 2]); + const { statSync } = await import('node:fs'); + expect(statSync(join(dir, 'f.bundle')).mode & 0o777).toBe(0o600); + }); + + it('a body that fails part-way rejects, leaves no temp file, and keeps the previous blob', async () => { + const store = createNodeBundleStore(dir); + store.put('f', new Uint8Array([9, 9])); + const failing = body([[1]], { + stream: () => ({ + async *[Symbol.asyncIterator]() { + yield new Uint8Array([1]); + throw new Error('stream broke'); + }, + }), + }); + await expect(store.putFrame?.('f', new Uint8Array([0]), failing)).rejects.toThrow( + 'stream broke', + ); + expect(readdirSync(dir)).toEqual(['f.bundle']); + expect([...(store.read('f') ?? [])]).toEqual([9, 9]); + }); + + it('never makes a half-written frame visible to list() while writing', async () => { + const store = createNodeBundleStore(dir); + let seen: string[] = []; + const slow = body([[1]], { + stream: () => ({ + async *[Symbol.asyncIterator]() { + yield new Uint8Array([1]); + seen = store.list(); + yield new Uint8Array([2]); + }, + }), + }); + await store.putFrame?.('f', new Uint8Array([0]), slow); + expect(seen).toEqual([]); + expect(store.list()).toEqual(['f']); + }); + + it('an empty body is just the header', async () => { + const store = createNodeBundleStore(dir); + await store.putFrame?.('f', new Uint8Array([5, 6]), body([])); + expect([...(store.read('f') ?? [])]).toEqual([5, 6]); + }); + }); }); diff --git a/packages/node-utils/src/bundle-store.ts b/packages/node-utils/src/bundle-store.ts index 6596cf0e..831bcc53 100644 --- a/packages/node-utils/src/bundle-store.ts +++ b/packages/node-utils/src/bundle-store.ts @@ -1,6 +1,8 @@ +import { open, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; -import type { BundleStore } from '@bugsee/core'; +import type { BundleBody, BundleStore } from '@bugsee/core'; import { ensureDir, listFiles, readFileBytes, remove, writeFileAtomic } from './fs-storage'; +import { writeFully } from './write-fully'; // Node implementation of core's BundleStore (the durable bundle queue's storage) — one // owner-only (0o600) file per pending bundle under `dir`, named `.bundle`. @bugsee/node points @@ -21,6 +23,33 @@ export function createNodeBundleStore(dir: string): BundleStore { // refers to a fully written file. Android holds the same invariant. writeFileAtomic(pathFor(id), bytes); }, + async putFrame(id: string, header: Uint8Array, body: BundleBody): Promise { + // The same atomic-publish discipline as `put`, with the body streamed: header, then each chunk, fsync, rename. The + // name only ever refers to a complete frame; a failure at any point removes the temp sibling and leaves an existing + // blob of that id untouched. + const file = pathFor(id); + const temp = `${file}.${process.pid}.tmp`; + try { + const handle = await open(temp, 'w', 0o600); + try { + await writeFully(handle, header); + for await (const chunk of body.stream()) { + await writeFully(handle, chunk); + } + await handle.sync(); // durable BEFORE the rename, so the rename cannot publish a short file + } finally { + await handle.close(); + } + await rename(temp, file); + } catch (error) { + try { + await unlink(temp); + } catch { + // already gone + } + throw error; + } + }, list(): string[] { return ( listFiles(dir) diff --git a/packages/node-utils/src/file-bundle-sink.ts b/packages/node-utils/src/file-bundle-sink.ts index cbe15b62..2b61faab 100644 --- a/packages/node-utils/src/file-bundle-sink.ts +++ b/packages/node-utils/src/file-bundle-sink.ts @@ -2,6 +2,7 @@ import { createReadStream, readdirSync, rmSync, statSync } from 'node:fs'; import { mkdir, open, readFile, stat, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import type { BundleBody, BundleSink, BundleSinkWriter } from '@bugsee/core'; +import { writeFully } from './write-fully'; // The node/electron-main sink for the streaming export (docs/design/streaming-export.md §4.5): the zip is written to a // temp file under `dir` as it is built, and the finished body streams it from disk — a report never holds its bundle in @@ -54,12 +55,7 @@ export function createFileBundleSink(dir: string): BundleSink { }; return { async write(chunk): Promise { - // A write may take less than it was given: continue from where it stopped, or the file is silently short. - let offset = 0; - while (offset < chunk.length) { - const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset); - offset += bytesWritten; - } + await writeFully(handle, chunk); // a short write must not leave the file silently short size += chunk.length; }, async finish(): Promise { diff --git a/packages/node-utils/src/write-fully.ts b/packages/node-utils/src/write-fully.ts new file mode 100644 index 00000000..378f518a --- /dev/null +++ b/packages/node-utils/src/write-fully.ts @@ -0,0 +1,10 @@ +import type { FileHandle } from 'node:fs/promises'; + +/** Write the whole chunk to the handle: one `write` may take less than it was given, so continue from where it stopped. */ +export async function writeFully(handle: FileHandle, chunk: Uint8Array): Promise { + let offset = 0; + while (offset < chunk.length) { + const { bytesWritten } = await handle.write(chunk, offset, chunk.length - offset); + offset += bytesWritten; + } +} From 44783735a6189f4928e4d59dbce211e307fa0ac4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 00:32:48 +0500 Subject: [PATCH 10/33] feat(core,node,browser): report assembly streams end to end (streaming export S5c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.ts assembles through assembleBundleStream over the exporter stream; the report dialog freezes its capture with its window; an export failure falls back to a memory build so the report still ships; node builds the zip into the instance export dir (file sink, stale sweep, incremental node:crypto digest) and browser registers the streaming replay encoder; the chunk store epoch follows early-stamped records so the window trim never drops in-window data. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- .../src/launch.stream-encoders.test.ts | 108 +++++++ packages/browser/src/launch.test.ts | 8 +- packages/browser/src/launch.ts | 9 + packages/core/src/chunk-capture-store.test.ts | 36 +++ packages/core/src/chunk-capture-store.ts | 7 +- packages/core/src/client.test.ts | 299 ++++++++++++++++++ packages/core/src/client.ts | 172 +++++++--- packages/core/src/trigger-pipeline.test.ts | 87 +++++ packages/core/src/trigger-pipeline.ts | 13 +- packages/core/src/upload-pipeline.test.ts | 55 ++++ packages/core/src/upload-pipeline.ts | 19 +- packages/node-utils/src/index.ts | 2 +- packages/node-utils/src/sha256.test.ts | 37 ++- packages/node-utils/src/sha256.ts | 13 + packages/node/src/instance-layout.test.ts | 1 + packages/node/src/instance-layout.ts | 3 + packages/node/src/launch.integration.test.ts | 20 +- packages/node/src/launch.test.ts | 64 ++++ packages/node/src/launch.ts | 24 +- 20 files changed, 923 insertions(+), 56 deletions(-) create mode 100644 packages/browser/src/launch.stream-encoders.test.ts diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 801bb798..4dab0a8b 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -215,7 +215,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | -| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c** client wiring. | | +| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Not streamed (residual):** capture-recovery and native-crash-recovery keep the buffered assembler (they re-use one drained capture for several bundles at launch); viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | diff --git a/packages/browser/src/launch.stream-encoders.test.ts b/packages/browser/src/launch.stream-encoders.test.ts new file mode 100644 index 00000000..a11395d9 --- /dev/null +++ b/packages/browser/src/launch.stream-encoders.test.ts @@ -0,0 +1,108 @@ +import 'fake-indexeddb/auto'; +import type { LockManagerLike } from '@bugsee/browser-utils'; +import { CaptureDataEntryBase } from '@bugsee/core'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { IDBFactory } from 'fake-indexeddb'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { type DeviceIdLocalStorage, resetBrowserDeviceIdCache } from './device-id'; +import type { BrowserProbe } from './environment'; +import { type Bugsee, type BugseeLaunchOptions, launch } from './launch'; + +// The streaming replay encoder reaches the report: launch hands registerReplay the shared `streamEncoders` map and the +// client reads it at assembly, so a replay entry in the window is encoded by what registerReplay put there. +const { registerReplay } = vi.hoisted(() => ({ registerReplay: vi.fn() })); +vi.mock('@bugsee/replay', () => ({ registerReplay })); + +const DEVICE_ID = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'; +const probe: BrowserProbe = { + userAgent: () => 'Mozilla/5.0 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, +}; +const freeLocks: LockManagerLike = { + request(name, options, callback) { + if (options.ifAvailable) return Promise.resolve(callback({ name })); + void callback({ name }); + return new Promise(() => {}); + }, +}; +const deviceIdLocalStorage = (): DeviceIdLocalStorage => { + const data = new Map([['bugsee.device_id', DEVICE_ID]]); + return { + getItem: (k) => data.get(k) ?? null, + setItem: (k, v) => void data.set(k, v), + removeItem: (k) => void data.delete(k), + }; +}; +const json = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); + +const clients: Bugsee[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop(0))); + resetBrowserDeviceIdCache(); + vi.clearAllMocks(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +describe('browser launch — streaming replay encoder', () => { + it('registers the replay stream encoder through registerReplay and uses it for the report', async () => { + const puts: Uint8Array[] = []; + const transport: NonNullable = async (url, options) => { + if (url.endsWith('/v2/sessions')) + return { status: 200, headers: {}, body: json({ access_token: 'a' }) }; + if (url.endsWith('/v2/issues')) + return { + status: 200, + headers: {}, + body: json({ endpoint: 'https://s3.test/put', issueId: 'i1', recordingId: 'r1' }), + }; + if (url === 'https://s3.test/put') puts.push(options?.body as Uint8Array); + return { status: 200, headers: {}, body: new Uint8Array() }; + }; + registerReplay.mockImplementation( + (_client: unknown, _file: unknown, o: { streamEncoders?: { replay?: unknown } }) => { + if (o.streamEncoders !== undefined) { + o.streamEncoders.replay = { + begin: () => ({ + write: () => [new TextEncoder().encode('S:')], + end: () => [new TextEncoder().encode('end')], + }), + }; + } + return { checkout: vi.fn() }; + }, + ); + const now = 1_000_000; + const client = await launch('tok', { + window: { addEventListener: () => {}, removeEventListener: () => {} } as unknown as Window, + document: { + visibilityState: 'visible', + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as Document, + transport, + systemProbe: probe, + captureNetwork: false, + captureViewHierarchy: false, + captureInteractions: false, + persist: false, + clock: { wallNow: () => now, monotonicNow: () => now }, + indexedDB: new IDBFactory(), + locks: freeLocks, + deviceIdEnv: { localStorage: deviceIdLocalStorage(), randomUUID: () => DEVICE_ID }, + } as BugseeLaunchOptions); + clients.push(client); + await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1)); + client.captureAggregator.addEntry(new CaptureDataEntryBase('replay', now, { type: 4 })); + await client.logException(new Error('x')); + expect(puts).toHaveLength(1); + const files = unzipSync(puts[0] as Uint8Array); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('S:end'); + }); +}); diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 3a0073d7..c52c645a 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -3947,7 +3947,11 @@ describe('launch — session replay (lazy)', () => { 'function', ); expect(call[1]).toEqual({}); // the shared fileEncoders map (replay writes its encoder into it) - expect(call[2]).toEqual({ blackout: expect.any(Object), checkoutEveryNms: 30_000 }); // default 60 s window → 30 s cadence; no caller overrides → replay's own fail-closed defaults apply + expect(call[2]).toEqual({ + blackout: expect.any(Object), + checkoutEveryNms: 30_000, + streamEncoders: {}, + }); // default 60 s window → 30 s cadence; no caller overrides → replay's own fail-closed defaults apply }); it('records when the document arrives via window.document (the real-browser path)', async () => { @@ -4034,6 +4038,7 @@ describe('launch — session replay (lazy)', () => { maskAllText: false, checkoutEveryNms: 5000, blackout: expect.any(Object), + streamEncoders: {}, }); // options forwarded }); @@ -4052,6 +4057,7 @@ describe('launch — session replay (lazy)', () => { expect(registerReplay.mock.calls[0]?.[2]).toEqual({ blackout: expect.any(Object), checkoutEveryNms: 30_000, + streamEncoders: {}, }); // no options object }); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 4d5835a4..7174727b 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -54,6 +54,7 @@ import { FAIL_CLOSED_BOOLEAN_OPTION, FAIL_CLOSED_BOOLEAN_OR_BAG_OPTION, FAIL_ON_BOOLEAN_OPTION, + type FileStreamEncoder, getCarrierClient, getCarrierPendingLaunch, getCarrierStatus, @@ -1000,6 +1001,12 @@ async function buildLaunch( const fileEncoders: Record<'replay', (payloads: unknown[]) => Uint8Array> | undefined = replayEnabled ? ({} as Record<'replay', (payloads: unknown[]) => Uint8Array>) : undefined; + // The streaming counterpart of `fileEncoders`: the replay registers its entry-by-entry encoder here, and the client reads + // the same map by reference at every report (so the lazy-loaded chunk can still add to it). + const streamEncoders: Record<'replay', FileStreamEncoder> | undefined = replayEnabled + ? ({} as Record<'replay', FileStreamEncoder>) + : undefined; + const client = createClient({ // Process-level state (the lifecycle listener, the blackout flag) lives on the carrier so it survives a // relaunch, which builds a new client. @@ -1025,6 +1032,7 @@ async function buildLaunch( // parses non-V8 stacks too (the detection providers already use it directly). stackParser: parseStack, ...(fileEncoders !== undefined ? { fileEncoders } : {}), + ...(streamEncoders !== undefined ? { streamEncoders } : {}), ...(reportSnapshots !== undefined ? { reportSnapshots } : {}), ...(reportMarkers !== undefined ? { reportMarkers: { store: reportMarkers, generation: captureGeneration } } @@ -1285,6 +1293,7 @@ async function buildLaunch( ) : undefined; const recorder = m.registerReplay(client, fileEncoders, { + ...(streamEncoders !== undefined ? { streamEncoders } : {}), // A base (FullSnapshot) must exist at the start of the window or the replay opens on orphans: snapshot at // least twice per window. An explicit positive replay.checkoutEveryNms wins; undefined / 0 / NaN do not // (they would silently restore the 60 s default, or disable periodic snapshots). diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 3f41caaa..98f8bcc8 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -333,6 +333,42 @@ describe('createChunkCaptureStore — snapshot time range', () => { expect(store.snapshot().timeRange?.()).toEqual({ start: 1000, end: 4000 }); }); + it('moves the epoch back to a record stamped before the store started, so the report keeps it (nothing in the window is trimmed)', () => { + const { clock, set } = mutableClock(10_000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 60_000, + }); + store.add({ type: 'log', timestamp: 4000, serialized: 'early' }); // e.g. an early-buffered pre-launch log + store.add({ type: 'log', timestamp: 9000, serialized: 'later' }); + set(12_000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 4000, end: 12_000 }); + }); + + it('an old record never pulls the start before the window', () => { + const { clock, set } = mutableClock(100_000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 5000, + }); + store.add({ type: 'log', timestamp: 4000, serialized: 'ancient' }); + set(120_000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 115_000, end: 120_000 }); + }); + + it('forgets the early record on clear() along with the rest of the epoch', () => { + const { clock, set } = mutableClock(10_000); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 60_000, + }); + store.add({ type: 'log', timestamp: 4000, serialized: 'early' }); + set(11_000); + store.clear(); + set(12_000); + expect(store.snapshot().timeRange?.()).toEqual({ start: 11_000, end: 12_000 }); + }); + it('restarts the epoch on clear()', () => { const { clock, set } = mutableClock(1000); const store = createChunkCaptureStore(createMemoryChunkBackend(), { diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index 8ffe03ce..68bcf229 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -129,7 +129,9 @@ export function createChunkCaptureStore( const firstStart = clock.wallNow(); let dataPart = openNewPart(firstStart, 'data'); - // When this store started recording (first part start since launch / the last clear): nothing older can exist. + // The earliest time this store holds (or held) data for: when it started recording (first part start since launch / the + // last clear), or an earlier record stamp (a log buffered before launch is replayed with its own time). Nothing older + // can be in the window, so the export range never starts before it, and never AFTER a record it would then trim. let epoch = firstStart; const partFor = (lane: Lane): IndexedPart => { @@ -248,6 +250,9 @@ export function createChunkCaptureStore( const size = backend.appendEntry(part.ref, record); part.byteSize += size; part.count += 1; + if (record.timestamp < epoch) { + epoch = record.timestamp; + } if (record.anchor === true) { part.anchor = true; } diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index e5630c22..13a0a248 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -4,6 +4,8 @@ import { strFromU8, unzipSync } from '@bugsee/util'; import { describe, expect, it, vi } from 'vitest'; import { addReportAttachment, decodeRequestFromMarker } from './attachments'; import { BlackoutToken } from './blackout'; +import { createMemoryBody } from './bundle-body'; +import { createMemorySink } from './bundle-sink'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; import { @@ -564,6 +566,303 @@ describe('createClient — file encoders', () => { }); }); +describe('createClient — streaming export', () => { + const mutableClock = (start: number) => { + let now = start; + const clock: Clock = { wallNow: () => now, monotonicNow: () => now }; + return { + clock, + set: (v: number) => { + now = v; + }, + }; + }; + const filesOf = (bundle: Bundle) => unzipSync(bundle.body as Uint8Array); + const manifestOf = (bundle: Bundle) => + JSON.parse(strFromU8(filesOf(bundle)['manifest.json'] as Uint8Array)) as { + time: { start: number; end: number }; + files: Array<{ filename: string }>; + }; + + it('exports the capture window: older records are trimmed and the manifest starts at the window start', async () => { + const t = mutableClock(100_000); + const store = createMemoryCaptureStore({ maxRecordingTimeMs: 5000, clock: t.clock }); + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + captureStore: store, + clock: t.clock, + uploadPipeline, + appToken: 'tok', + getEnvironment, + }); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', 100_000, { msg: 'old' })); + t.set(103_000); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', 103_000, { msg: 'new' })); + t.set(106_000); + await client.logException(new Error('x')); + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + expect(manifestOf(bundle).time).toEqual({ start: 101_000, end: 106_000 }); + const logs = JSON.parse(strFromU8(filesOf(bundle)['logs.json'] as Uint8Array)) as Array<{ + msg: string; + }>; + expect(logs.map((l) => l.msg)).toEqual(['new']); + }); + + it('threads streamEncoders, which win over fileEncoders and see the window plan', async () => { + const t = mutableClock(100_000); + const { uploadPipeline, enqueue } = fakeUpload(); + const plans: unknown[] = []; + const client = createClient({ + clock: t.clock, + uploadPipeline, + appToken: 'tok', + getEnvironment, + reportSnapshots: [() => [new CaptureDataEntryBase('replay', 100_000, { e: 1 })]], + fileEncoders: { replay: () => new Uint8Array([1]) }, + streamEncoders: { + replay: { + begin: (plan) => { + plans.push(plan); + return { write: () => [new Uint8Array([9, 9])], end: () => [] }; + }, + }, + }, + }); + await client.logException(new Error('x')); + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + expect(Array.from(filesOf(bundle)['replay.bin'] as Uint8Array)).toEqual([9, 9]); + expect(plans).toHaveLength(1); + }); + + it('releases the store snapshot once the report is built', async () => { + const store = createMemoryCaptureStore({}); + const release = vi.fn(); + const realSnapshot = store.snapshot.bind(store); + store.snapshot = (options) => { + const snapshot = realSnapshot(options); + return { ...snapshot, release: () => (release(), snapshot.release()) }; + }; + const { uploadPipeline } = fakeUpload(); + const client = createClient({ + captureStore: store, + uploadPipeline, + appToken: 'tok', + getEnvironment, + }); + await client.logException(new Error('x')); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('hands a non-memory sink body to the upload as a body, and plain bytes for the default sink', async () => { + const body = createMemoryBody([new Uint8Array([1])]); + const lazy = { ...body, peek: () => undefined }; + const sink = { + open: () => ({ + write: () => {}, + finish: async () => lazy, + abort: () => {}, + }), + }; + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + uploadPipeline, + appToken: 'tok', + getEnvironment, + bundleSink: sink, + }); + await client.logException(new Error('x')); + expect((enqueue.mock.calls[0]?.[0] as Bundle).body).toBe(lazy); + + const plain = fakeUpload(); + const client2 = createClient({ + uploadPipeline: plain.uploadPipeline, + appToken: 'tok', + getEnvironment, + }); + await client2.logException(new Error('x')); + expect(ArrayBuffer.isView((plain.enqueue.mock.calls[0]?.[0] as Bundle).body)).toBe(true); + }); + + it('releases the snapshot when gathering the report-time snapshots itself fails', async () => { + const store = createMemoryCaptureStore({}); + const release = vi.fn(); + const realSnapshot = store.snapshot.bind(store); + store.snapshot = (options) => { + const snapshot = realSnapshot(options); + return { ...snapshot, release: () => (release(), snapshot.release()) }; + }; + const { uploadPipeline } = fakeUpload(); + const client = createClient({ + captureStore: store, + uploadPipeline, + appToken: 'tok', + getEnvironment, + reportSnapshots: [() => Promise.reject(new Error('source failed'))], + onError: () => { + throw new Error('the error sink itself fails'); + }, + }); + await client.logException(new Error('x')); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('a sink that cannot even open (unwritable export dir) still ships the report, through memory', async () => { + const sink = { open: () => Promise.reject(new Error('EACCES: export dir')) }; + const onError = vi.fn(); + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + uploadPipeline, + appToken: 'tok', + getEnvironment, + bundleSink: sink, + onError, + reportSnapshots: [() => [new CaptureDataEntryBase('viewtree', 1, { v: 1 })]], + }); + const result = await client.logException(new Error('x')); + expect(result).toEqual({ ok: true }); + expect(onError).toHaveBeenCalled(); + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + expect(ArrayBuffer.isView(bundle.body)).toBe(true); // built in memory, not through the failed sink + expect(Object.keys(filesOf(bundle))).toContain('request.json'); + expect(Object.keys(filesOf(bundle))).toContain('viewtree.json'); // the report-time snapshot survives + }); + + it('releases the live snapshot when building the report fails before the assembler has it', async () => { + const store = createMemoryCaptureStore({}); + const release = vi.fn(); + const realSnapshot = store.snapshot.bind(store); + store.snapshot = (options) => { + const snapshot = realSnapshot(options); + return { ...snapshot, release: () => (release(), snapshot.release()) }; + }; + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + captureStore: store, + uploadPipeline, + appToken: 'tok', + getEnvironment: () => { + throw new Error('environment getter failed'); + }, + }); + const result = await client.logException(new Error('x')); + expect(result.ok).toBe(false); + expect(enqueue).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); + }); + + it('a sink that cannot write costs the data, not the report: the request still ships and the failure is reported', async () => { + let attempts = 0; + const sink = { + open: () => { + attempts += 1; + if (attempts === 1) { + return { + write: () => { + throw new Error('disk full'); + }, + finish: async () => createMemoryBody([]), + abort: () => {}, + }; + } + return createMemorySink().open(); + }, + }; + const onError = vi.fn(); + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + uploadPipeline, + appToken: 'tok', + getEnvironment, + bundleSink: sink, + onError, + }); + client.captureAggregator.addEntry( + new CaptureDataEntryBase('log', 1, { msg: 'lost with the export' }), + ); + const result = await client.logException(new Error('x')); + expect(result).toEqual({ ok: true }); + expect(onError).toHaveBeenCalled(); + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + expect(Object.keys(filesOf(bundle))).toContain('request.json'); + expect(filesOf(bundle)['logs.json']).toBeUndefined(); + }); + + it('a report dialog keeps the capture as of when it was opened, with the window it covered, and releases the snapshot at once', async () => { + const t = mutableClock(100_000); + const store = createMemoryCaptureStore({ maxRecordingTimeMs: 5000, clock: t.clock }); + const release = vi.fn(); + const realSnapshot = store.snapshot.bind(store); + store.snapshot = (options) => { + const snapshot = realSnapshot(options); + return { ...snapshot, release: () => (release(), snapshot.release()) }; + }; + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + captureStore: store, + clock: t.clock, + uploadPipeline, + appToken: 'tok', + getEnvironment, + }); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', 100_500, { msg: 'before' })); + t.set(102_000); + const prepared = client.prepareUpload(); + await flushMicrotasks(); + expect(release).toHaveBeenCalledTimes(1); // frozen: read out, so an abandoned dialog pins nothing + t.set(150_000); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', 150_000, { msg: 'after' })); + await prepared?.send({ summary: 's' }); + const bundle = enqueue.mock.calls[0]?.[0] as Bundle; + const logs = JSON.parse(strFromU8(filesOf(bundle)['logs.json'] as Uint8Array)) as Array<{ + msg: string; + }>; + expect(logs.map((l) => l.msg)).toEqual(['before']); + expect(manifestOf(bundle).time.start).toBe(100_000); // the window as it was frozen, not as of Send + }); + + it('a frozen capture that cannot be read releases the snapshot and falls back to a live one', async () => { + const store = createMemoryCaptureStore({}); + let first = true; + const release = vi.fn(); + const realSnapshot = store.snapshot.bind(store); + store.snapshot = (options) => { + const snapshot = realSnapshot(options); + if (first) { + first = false; + return { + ...snapshot, + stream: () => ({ + next: () => Promise.reject(new Error('unreadable')), + return: async () => ({ done: true as const, value: undefined }), + [Symbol.asyncIterator]() { + return this; + }, + }), + release: () => (release(), snapshot.release()), + }; + } + return snapshot; + }; + const { uploadPipeline, enqueue } = fakeUpload(); + const client = createClient({ + captureStore: store, + uploadPipeline, + appToken: 'tok', + getEnvironment, + }); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', Date.now(), { msg: 'live' })); + const prepared = client.prepareUpload(); + await flushMicrotasks(); + expect(await prepared?.send({ summary: 's' })).toEqual({ ok: true }); + expect(enqueue).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); // the unreadable snapshot was released, not left pinned + const logs = JSON.parse( + strFromU8(filesOf(enqueue.mock.calls[0]?.[0] as Bundle)['logs.json'] as Uint8Array), + ) as Array<{ msg: string }>; + expect(logs.map((l) => l.msg)).toEqual(['live']); // and the report fell back to a live capture + }); +}); + describe('createClient — crash.json', () => { const crashOf = (bundle: Bundle) => JSON.parse(strFromU8(unzipSync(bundle.body as Uint8Array)['crash.json'] as Uint8Array)); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index ddddf88b..64b4e1dd 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -31,7 +31,8 @@ import { reportChangeSignature, } from './attachments'; import { type BlackoutState, BlackoutToken, createBlackoutState } from './blackout'; -import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +import type { BundleAssemblyContext } from './bundle-assembler'; +import { assembleBundleStream } from './bundle-stream-assembler'; import { createCaptureAggregator } from './capture-aggregator'; import { createCaptureCoordinator, type OptionGate } from './capture-coordinator'; import { CaptureDataEntryBase } from './capture-data-entry'; @@ -50,6 +51,7 @@ import { import { type Clock, ClockToken, createSystemClock } from './clock'; import { type CaptureDataEntry, + type CaptureEntryStream, type CaptureProviderInit, type CaptureStore, CaptureStoreToken, @@ -638,6 +640,17 @@ export interface CreateClientOptions { * extension loaded AFTER `createClient` (replay is lazy-loaded) can add its encoder into it. Default none. */ fileEncoders?: BundleAssemblyContext['fileEncoders']; + /** + * Per-file-type STREAMING encoders (the replay's `replay.bin`), threaded into the bundle assembler like `fileEncoders` and + * read by reference each report. A type with one is encoded entry by entry (and clamped/trimmed to the report window by + * the encoder); it wins over `fileEncoders` for the same type. Default none. + */ + streamEncoders?: BundleAssemblyContext['streamEncoders']; + /** + * Where a report's zip is written while it is built (a node temp file, a Blob). Default: memory, and the bundle then carries + * plain bytes exactly as before; any other sink makes `Bundle.body` a {@link BundleBody} the upload owns. + */ + bundleSink?: BundleAssemblyContext['sink']; /** Trigger pipeline override; when omitted, built from uploadPipeline + appToken + getEnvironment. */ triggerPipeline?: TriggerPipeline; /** @@ -700,6 +713,33 @@ interface ReportIdentity { userIdentifier: string | null; } +/** What a report is built from: the rolling capture as an entry stream (with the time range it covers) and the report-time extras. */ +interface ExportedCapture { + readonly source: CaptureEntryStream; + readonly extras: ReadonlyMap; +} + +/** An entry source with nothing in it (and no time range), for a report whose capture could not be exported. */ +function emptyCapture(): CaptureEntryStream { + return arrayCapture([], undefined); +} + +/** An already-read capture as a re-iterable entry source carrying the time range it was taken for. */ +function arrayCapture( + entries: readonly CaptureDataEntry[], + timeRange: CaptureEntryStream['timeRange'], +): CaptureEntryStream { + const stream: CaptureEntryStream = { + ...(timeRange !== undefined ? { timeRange } : {}), + next: () => iterator.next(), + [Symbol.asyncIterator]: () => stream, + }; + const iterator = (async function* (): AsyncGenerator { + yield* entries; + })(); + return stream; +} + export function createClient(options: CreateClientOptions = {}): CreatedClient { const clock = options.clock ?? createSystemClock(); const isEnabled = options.isEnabled ?? (() => true); @@ -910,12 +950,23 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { let killed = false; let tickTimer: unknown = null; // Captures frozen by `prepareUpload`, keyed by the request they will be sent with (see assemble). - const frozenCaptures = new WeakMap< - ReportingRequest, - Promise> - >(); - let collectCapture: (owner?: string) => Promise> = async () => - new Map(); + const frozenCaptures = new WeakMap>(); + let openCapture: (owner?: string) => Promise = async () => ({ + source: emptyCapture(), + extras: new Map(), + }); + + // The capture a report dialog holds from the moment it opened: read out in full NOW (the snapshot is released as soon as + // it is read, so an abandoned dialog pins nothing), with the time range the snapshot covered. + const freezeCapture = async (owner?: string): Promise => { + const { source, extras } = await openCapture(owner); + const entries: CaptureDataEntry[] = []; + // (A read that fails ends the stream, which releases its snapshot itself.) + for await (const entry of source) { + entries.push(entry); + } + return { source: arrayCapture(entries, source.timeRange), extras }; + }; // Build the trigger pipeline from the report assembler when its inputs are present (unless an // override is injected). assemble reads the exporter drain + the live environment/attributes. @@ -1014,30 +1065,28 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { }; let triggerPipeline = options.triggerPipeline; if (triggerPipeline === undefined && uploadPipeline && appToken !== undefined && getEnvironment) { - // The captured data for a report: the exporter's drain plus the report-time snapshot sources (e.g. the - // DOM viewtree). Used LIVE at assembly, and EARLY by `prepareUpload` so the dialog's report is the - // moment it was opened, not the moment Send was pressed. + // The captured data for a report: a STREAM over the store's snapshot (taken now, released when the export ends) plus the + // report-time snapshot sources (e.g. the DOM viewtree), gathered here. Used LIVE at assembly, and EARLY by + // `prepareUpload` (which materializes it) so the dialog's report is the moment it was opened, not the moment Send was + // pressed. // - // Scope the drain to the TENANT that faulted. On a multi-tenant isolate (Cloudflare Durable Objects) - // an unscoped drain would put every other tenant's capture into this bundle — the leak proven on real - // workerd. `owner` is undefined everywhere else, where the - // partitioned store is a no-op and this is byte-identical to an unscoped drain. - collectCapture = async (owner?: string): Promise> => { - const capturedByType = await captureExporter.drain( - owner !== undefined ? { owner } : undefined, - ); - // Merge report-time snapshots into the drained map. Each source is isolated: a throw goes to - // onError and the report still uploads (a missing snapshot must never block delivery). + // Scope the snapshot to the TENANT that faulted. On a multi-tenant isolate (Cloudflare Durable Objects) an unscoped one + // would put every other tenant's capture into this bundle — the leak proven on real workerd. `owner` is undefined + // everywhere else, where the partitioned store is a no-op and this is byte-identical to an unscoped snapshot. + const gatherSnapshots = async (): Promise> => { + const extras = new Map(); + // Each source is isolated: a throw goes to onError and the report still uploads (a missing snapshot must never + // block delivery). if (options.reportSnapshots !== undefined) { const snapshotAt = clock.wallNow(); for (const source of options.reportSnapshots) { try { for (const entry of await source(snapshotAt)) { - const existing = capturedByType.get(entry.type); + const existing = extras.get(entry.type); if (existing !== undefined) { existing.push(entry); } else { - capturedByType.set(entry.type, [entry]); + extras.set(entry.type, [entry]); } } } catch (error) { @@ -1045,7 +1094,16 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { } } } - return capturedByType; + return extras; + }; + openCapture = async (owner?: string): Promise => { + const stream = captureExporter.stream(owner !== undefined ? { owner } : undefined); + try { + return { source: stream, extras: await gatherSnapshots() }; + } catch (error) { + await stream.return?.(); // the snapshot is pinned until released + throw error; + } }; const assemble = async (request: ReportingRequest): Promise => { lifecycle.emit(LifecycleEvents.BeforeReportAssembled, { reportId: request.id }); @@ -1071,9 +1129,23 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { // A prepared upload carries the capture it froze; anything else drains live. A frozen capture that // could not be read falls back to the live one — a stale-but-present report beats none. const frozen = frozenCaptures.get(request); - const capturedByType = + const capture = (frozen !== undefined ? await frozen.catch(() => undefined) : undefined) ?? - (await collectCapture(reportContext?.owner)); + (await openCapture(reportContext?.owner)); + try { + return await buildReport(request, capture, reportContext); + } catch (error) { + // The live snapshot stays pinned until its stream ends, and an unread stream never runs its own cleanup: close it + // when anything between opening it and handing it to the assembler throws. + await capture.source.return?.(); + throw error; + } + }; + const buildReport = async ( + request: ReportingRequest, + capture: ExportedCapture, + reportContext: RequestContext | undefined, + ): Promise => { // ReportHandler.after (Android `onAfterReportCreated`): the report exists and `before` has had its say; // assembly has not started. The handler may still mutate the request (labels, attributes, // attachments) and the bundle below carries the result. Reaching here is what "created" means, so a @@ -1116,22 +1188,46 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { // The durable queue writes it into its frame header, which is the only thing that later lets // recovery tell "this staged blob IS the incident that marker is still holding open" from "this // staged blob is an incident nothing has uploaded yet". See IdentifiedBundle. + const assembled = await assembleWithFallback(request, capture, { + appToken, + environment: getEnvironment(), + attributes: identity.attributes, + userIdentifier: identity.userIdentifier, + clock, + onError: onErrorSafe, + ...(captured !== undefined ? { requestContext: captured } : {}), + ...(options.bundleFileName !== undefined ? { fileName: options.bundleFileName } : {}), + ...(options.fileEncoders !== undefined ? { fileEncoders: options.fileEncoders } : {}), + ...(options.streamEncoders !== undefined ? { streamEncoders: options.streamEncoders } : {}), + ...(options.bundleSink !== undefined ? { sink: options.bundleSink } : {}), + }); const bundle: IdentifiedBundle = { - ...assembleBundle(request, capturedByType, { - appToken, - environment: getEnvironment(), - attributes: identity.attributes, - userIdentifier: identity.userIdentifier, - clock, - onError: onErrorSafe, - ...(captured !== undefined ? { requestContext: captured } : {}), - ...(options.bundleFileName !== undefined ? { fileName: options.bundleFileName } : {}), - ...(options.fileEncoders !== undefined ? { fileEncoders: options.fileEncoders } : {}), - }), + request: assembled.request, + fileName: assembled.fileName, + // The default (memory) sink keeps handing out plain bytes, as every assembler did; any other sink's body is + // passed on for the upload to own. + body: assembled.body.peek() ?? assembled.body, reportId: request.id, }; return bundle; }; + // Build the bundle from the capture. A failure of the export itself (the sink cannot write, the snapshot cannot be + // read) must cost the DATA, not the report: the request, manifest and crash still ship, with the failure reported. + const assembleWithFallback = async ( + request: ReportingRequest, + capture: ExportedCapture, + context: BundleAssemblyContext, + ): Promise>> => { + try { + return await assembleBundleStream(request, capture.source, context, capture.extras); + } catch (error) { + onErrorSafe(error); + // Not through the same sink: when the sink is what failed (an unwritable export dir, a full disk) the retry must + // not fail the same way. Memory always works, and the report is small without its rolling capture (the report-time extras are already in memory and stay). + const { sink: _failed, ...inMemory } = context; + return await assembleBundleStream(request, emptyCapture(), inMemory, capture.extras); + } + }; triggerPipeline = createTriggerPipeline({ assemble, uploadPipeline }); } @@ -1156,7 +1252,7 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { // Build, filter and submit a report someone explicitly asked for (`upload` / a prepared upload). const sendUpload = ( uploadOptions: InternalUploadOptions, - frozen?: Promise>, + frozen?: Promise, ): Promise => { const request = createReportingRequest({ source: { type: uploadOptions.trigger ?? 'code_upload' }, @@ -1757,7 +1853,7 @@ export function createClient(options: CreateClientOptions = {}): CreatedClient { } catch { owner = undefined; } - const frozen = collectCapture(owner); + const frozen = freezeCapture(owner); // A rejected freeze is handled at assembly (live-drain fallback); this keeps it from being reported // as unhandled in the meantime. frozen.catch(() => undefined); diff --git a/packages/core/src/trigger-pipeline.test.ts b/packages/core/src/trigger-pipeline.test.ts index c29ee109..644aa6a4 100644 --- a/packages/core/src/trigger-pipeline.test.ts +++ b/packages/core/src/trigger-pipeline.test.ts @@ -1,5 +1,6 @@ import { createDeferred } from '@bugsee/util'; import { describe, expect, it, vi } from 'vitest'; +import { createMemoryBody } from './bundle-body'; import { BugseeError } from './errors'; import { createReportingRequest, type ReportingRequest } from './reporting'; import type { Bundle, UploadPipeline, UploadResult } from './transport'; @@ -203,3 +204,89 @@ describe('createTriggerPipeline — a burst of concurrent incidents', () => { expect(third.error?.message).toMatch(/queue overflow/); }); }); + +describe('createTriggerPipeline — body ownership', () => { + it('disposes an assembled body when the upload pipeline throws instead of resolving (nothing else owns it then)', async () => { + const dispose = vi.fn(); + const body = { ...createMemoryBody([new Uint8Array([1])]), dispose }; + const pipeline = createTriggerPipeline({ + assemble: () => ({ request: {} as never, body, fileName: 'a.zip' }), + uploadPipeline: { + enqueue: () => Promise.reject(new Error('pipeline blew up')), + flush: async () => true, + drop: () => {}, + }, + }); + const result = await pipeline.report({} as never); + expect(result.ok).toBe(false); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('a rejecting dispose leaves no unhandled rejection', async () => { + const unhandled: unknown[] = []; + const proc = ( + globalThis as unknown as { + process: { + on: (e: string, f: (r: unknown) => void) => void; + off: (e: string, f: (r: unknown) => void) => void; + }; + } + ).process; + const listener = (reason: unknown): void => { + unhandled.push(reason); + }; + proc.on('unhandledRejection', listener); + try { + // a plain function (not vi.fn): a mock attaches its own handler to a returned promise and would hide the leak + const body = { + ...createMemoryBody([new Uint8Array([1])]), + dispose: () => Promise.reject(new Error('dispose failed')), + }; + const pipeline = createTriggerPipeline({ + assemble: () => ({ request: {} as never, body, fileName: 'a.zip' }), + uploadPipeline: { + enqueue: () => Promise.reject(new Error('boom')), + flush: async () => true, + drop: () => {}, + }, + }); + expect((await pipeline.report({} as never)).ok).toBe(false); + const timer = ( + globalThis as unknown as { setTimeout: (f: () => void, ms: number) => unknown } + ).setTimeout; + await new Promise((resolve) => timer(() => resolve(), 20)); + expect(unhandled).toEqual([]); + } finally { + proc.off('unhandledRejection', listener); + } + }); + + it('does not dispose a body the upload pipeline took (it owns it from enqueue on)', async () => { + const dispose = vi.fn(); + const body = { ...createMemoryBody([new Uint8Array([1])]), dispose }; + const pipeline = createTriggerPipeline({ + assemble: () => ({ request: {} as never, body, fileName: 'a.zip' }), + uploadPipeline: { + enqueue: async () => ({ ok: true }), + flush: async () => true, + drop: () => {}, + }, + }); + await pipeline.report({} as never); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('a failing assembly has no body to dispose', async () => { + const pipeline = createTriggerPipeline({ + assemble: () => { + throw new Error('assembly failed'); + }, + uploadPipeline: { + enqueue: async () => ({ ok: true }), + flush: async () => true, + drop: () => {}, + }, + }); + expect((await pipeline.report({} as never)).ok).toBe(false); + }); +}); diff --git a/packages/core/src/trigger-pipeline.ts b/packages/core/src/trigger-pipeline.ts index 9318d9c7..3e8b0fad 100644 --- a/packages/core/src/trigger-pipeline.ts +++ b/packages/core/src/trigger-pipeline.ts @@ -1,3 +1,4 @@ +import { isBundleBody } from './bundle-body'; import { BugseeError } from './errors'; import type { ReportingRequest } from './reporting'; import type { Bundle, UploadPipeline, UploadResult } from './transport'; @@ -45,10 +46,20 @@ export function createTriggerPipeline(options: TriggerPipelineOptions): TriggerP const queue: Array<{ request: ReportingRequest; resolve: (result: UploadResult) => void }> = []; const assembleAndEnqueue = async (request: ReportingRequest): Promise => { + let bundle: Bundle | undefined; try { - const bundle = await assemble(request); + bundle = await assemble(request); return await uploadPipeline.enqueue(bundle); } catch (err) { + // An upload pipeline resolves (it owns and disposes the body from `enqueue` on), so reaching here with a bundle means + // it threw instead: nothing else will release a file/Blob-backed body. + if (bundle !== undefined && isBundleBody(bundle.body)) { + try { + void Promise.resolve(bundle.body.dispose()).catch(() => {}); + } catch { + // releasing must never change the outcome + } + } return { ok: false, error: diff --git a/packages/core/src/upload-pipeline.test.ts b/packages/core/src/upload-pipeline.test.ts index 0c3f230f..c615f780 100644 --- a/packages/core/src/upload-pipeline.test.ts +++ b/packages/core/src/upload-pipeline.test.ts @@ -128,6 +128,61 @@ describe('createUploadPipeline — happy path', () => { }); }); + it('a body that is not in memory is hashed by sha256Stream (incrementally) when one is given', async () => { + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const sha256 = vi.fn(async () => 'one-shot'); + const seen: number[] = []; + const sha256Stream = vi.fn(async (chunks: AsyncIterable) => { + for await (const chunk of chunks) seen.push(...chunk); + return 'streamed'; + }); + const base = createMemoryBody([new Uint8Array([1, 2]), new Uint8Array([3])]); + const bytes = vi.fn(base.bytes); + const body = { ...base, peek: () => undefined, bytes }; + await createUploadPipeline(deps({ uploader: fakeUploader(put), sha256, sha256Stream })).enqueue( + { + ...bundle, + body, + }, + ); + expect(seen).toEqual([1, 2, 3]); + expect(sha256).not.toHaveBeenCalled(); + expect(bytes).not.toHaveBeenCalled(); + expect((put.mock.calls[0] as unknown as [string, unknown, PutBundleOptions])[2]).toMatchObject({ + checksumSha256: 'streamed', + }); + }); + + it('an in-memory body still uses the one-shot digest even when sha256Stream exists', async () => { + const sha256 = vi.fn(async () => 'one-shot'); + const sha256Stream = vi.fn(async () => 'streamed'); + const put = vi.fn(async () => ({ ok: true }) as PutResult); + await createUploadPipeline(deps({ uploader: fakeUploader(put), sha256, sha256Stream })).enqueue( + { + ...bundle, + body: createMemoryBody([new Uint8Array([1])]), + }, + ); + expect(sha256).toHaveBeenCalledTimes(1); + expect(sha256Stream).not.toHaveBeenCalled(); + }); + + it('a streaming digest that fails uploads without a checksum rather than failing the upload', async () => { + const put = vi.fn(async () => ({ ok: true }) as PutResult); + const body = { ...createMemoryBody([new Uint8Array([1])]), peek: () => undefined }; + const result = await createUploadPipeline( + deps({ + uploader: fakeUploader(put), + sha256Stream: () => Promise.reject(new Error('hash failed')), + }), + ).enqueue({ ...bundle, body }); + expect(result.ok).toBe(true); + expect((put.mock.calls[0] as unknown as [string, unknown, PutBundleOptions])[2]).toEqual({ + contentLength: 1, + fileName: 'a.bundle.zip', + }); + }); + it('a body that cannot be read fails the upload (no throw, nothing created on the collector), retryable', async () => { const api = fakeApi(); const put = vi.fn(async () => ({ ok: true }) as PutResult); diff --git a/packages/core/src/upload-pipeline.ts b/packages/core/src/upload-pipeline.ts index abd2d1b4..24192a26 100644 --- a/packages/core/src/upload-pipeline.ts +++ b/packages/core/src/upload-pipeline.ts @@ -1,5 +1,5 @@ import { computeBackoff, sha256Hex } from '@bugsee/util'; -import { asBody, isBundleBody } from './bundle-body'; +import { asBody, type BundleBody, isBundleBody } from './bundle-body'; import { BugseeError } from './errors'; import { type BugseeApi, @@ -40,6 +40,11 @@ export interface UploadPipelineOptions { /** Hex SHA-256 of the body for the PUT checksum. Default util.sha256Hex. Best-effort: a rejection * uploads the bundle without a checksum rather than failing it. */ sha256?: (body: Uint8Array) => Promise; + /** + * The same digest for a body that is NOT held in memory (a temp file), read as a stream and hashed incrementally so it is + * never pulled into the heap for it. Absent: such a body is uploaded without a checksum (nothing sends it today). + */ + sha256Stream?: (chunks: AsyncIterable) => Promise; /** Delay primitive (injected for tests). Default setTimeout-based. */ sleep?: (ms: number) => Promise; /** Backoff delay for retry `attempt` (1-based). Default util.computeBackoff. */ @@ -186,6 +191,15 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } }; + /** The digest of a body that is not in memory, hashed from its stream; `undefined` without a stream hasher or on failure. */ + const streamChecksumOf = async (body: BundleBody): Promise => { + try { + return await options.sha256Stream?.(body.stream()); + } catch { + return undefined; // best-effort, like the one-shot digest + } + }; + const runOperation = async (bundle: Bundle, category: OutcomeCategory): Promise => { // BEFORE the issue: creating it is what leaves a record on the collector, so local work runs first — // and iOS carries `bundle_sha256` in the issue-create body itself, which needs the digest by then. @@ -205,7 +219,8 @@ export function createUploadPipeline(options: UploadPipelineOptions): UploadPipe } // Hashed only when the body is already in memory: a file/Blob body is never pulled into memory for a digest nothing sends. const held = body.peek(); - const checksumSha256 = held !== undefined ? await checksumOf(held) : undefined; + const checksumSha256 = + held !== undefined ? await checksumOf(held) : await streamChecksumOf(body); let issue: IssueCreateResult; try { diff --git a/packages/node-utils/src/index.ts b/packages/node-utils/src/index.ts index ac9ddadf..e69cba41 100644 --- a/packages/node-utils/src/index.ts +++ b/packages/node-utils/src/index.ts @@ -32,5 +32,5 @@ export { export { httpRequest, transportFor } from './http-request'; export { createNodeReportMarkerStore } from './report-marker-store'; // node:crypto upload-checksum digest, injected into core's upload pipeline where WebCrypto is absent. -export { nodeSha256Fallback, nodeSha256Hex, type Sha256Digest } from './sha256'; +export { nodeSha256Fallback, nodeSha256Hex, nodeSha256Stream, type Sha256Digest } from './sha256'; export { createWorkerThreadRingWorker } from './worker-ring-worker'; diff --git a/packages/node-utils/src/sha256.test.ts b/packages/node-utils/src/sha256.test.ts index eb99ba7c..2325f693 100644 --- a/packages/node-utils/src/sha256.test.ts +++ b/packages/node-utils/src/sha256.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { nodeSha256Fallback, nodeSha256Hex } from './sha256'; +import { nodeSha256Fallback, nodeSha256Hex, nodeSha256Stream } from './sha256'; const ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; @@ -79,3 +79,38 @@ describe('nodeSha256Fallback', () => { expect(nodeSha256Fallback()).toBe(nodeSha256Hex); }); }); + +describe('nodeSha256Stream', () => { + async function* chunks(parts: Uint8Array[]): AsyncIterable { + for (const part of parts) yield part; + } + + it('hashes chunks exactly as the one-shot digest hashes their concatenation', async () => { + const parts = [ + new TextEncoder().encode('a'), + new TextEncoder().encode('b'), + new TextEncoder().encode('c'), + ]; + expect(await nodeSha256Stream(chunks(parts))).toBe(ABC); + }); + + it('hashes an empty stream as the empty body', async () => { + expect(await nodeSha256Stream(chunks([]))).toBe( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + ); + }); + + it('agrees with WebCrypto over a body split at arbitrary points', async () => { + const body = new Uint8Array(100_000).map((_, i) => (i * 31) % 251); + const parts = [body.subarray(0, 1), body.subarray(1, 4097), body.subarray(4097)]; + expect(await nodeSha256Stream(chunks(parts))).toBe(await webCryptoHex(body)); + }); + + it('rejects when the stream fails part-way', async () => { + async function* broken(): AsyncIterable { + yield new Uint8Array([1]); + throw new Error('read failed'); + } + await expect(nodeSha256Stream(broken())).rejects.toThrow('read failed'); + }); +}); diff --git a/packages/node-utils/src/sha256.ts b/packages/node-utils/src/sha256.ts index 4f74eae2..9a1150db 100644 --- a/packages/node-utils/src/sha256.ts +++ b/packages/node-utils/src/sha256.ts @@ -27,3 +27,16 @@ export function nodeSha256Fallback( ): Sha256Digest | undefined { return runtime.crypto?.subtle ? undefined : nodeSha256Hex; } + +/** + * Hex SHA-256 of a body read as a stream, hashed incrementally: the digest a bundle that is NOT held in memory (a temp + * file) gets, which WebCrypto's one-shot `digest` cannot give without reading the whole file into the heap first. Hashing + * runs chunk by chunk between reads, so a large bundle never blocks the event loop for its whole length either. + */ +export async function nodeSha256Stream(chunks: AsyncIterable): Promise { + const hash = createHash('sha256'); + for await (const chunk of chunks) { + hash.update(chunk); + } + return hash.digest('hex'); +} diff --git a/packages/node/src/instance-layout.test.ts b/packages/node/src/instance-layout.test.ts index eadba800..f2ec7a43 100644 --- a/packages/node/src/instance-layout.test.ts +++ b/packages/node/src/instance-layout.test.ts @@ -34,6 +34,7 @@ describe('createInstanceLayout', () => { expect(layout.root).toBe(join('/data', '42-3-abc')); expect(layout.captureDir).toBe(join('/data', '42-3-abc', 'capture')); expect(layout.pendingDir).toBe(join('/data', '42-3-abc', 'pending')); + expect(layout.exportDir).toBe(join('/data', '42-3-abc', 'export')); expect(layout.incidentsDir).toBe(join('/data', '42-3-abc', 'incidents')); expect(layout.liveFile).toBe(join('/data', '42-3-abc', '.live')); expect(layout.ownerFile).toBe(join('/data', '42-3-abc', 'owner.json')); diff --git a/packages/node/src/instance-layout.ts b/packages/node/src/instance-layout.ts index 2c9234ba..9b1c5ad5 100644 --- a/packages/node/src/instance-layout.ts +++ b/packages/node/src/instance-layout.ts @@ -28,6 +28,8 @@ export interface InstanceLayout { readonly root: string; readonly captureDir: string; readonly pendingDir: string; + /** Where a report's zip is written while it is built (see `createFileBundleSink`); empty again once each upload is over. */ + readonly exportDir: string; readonly incidentsDir: string; /** The liveness heartbeat file — its mtime is touched from the watchdog thread. */ readonly liveFile: string; @@ -73,6 +75,7 @@ export function createInstanceLayout( root, captureDir: join(root, 'capture'), pendingDir: join(root, 'pending'), + exportDir: join(root, 'export'), incidentsDir: join(root, 'incidents'), liveFile: join(root, LIVE_FILE), ownerFile: join(root, OWNER_FILE), diff --git a/packages/node/src/launch.integration.test.ts b/packages/node/src/launch.integration.test.ts index 54c7f912..9c216f24 100644 --- a/packages/node/src/launch.integration.test.ts +++ b/packages/node/src/launch.integration.test.ts @@ -176,8 +176,11 @@ describe('launch — loopback end-to-end', () => { rmSync(dataDir, { recursive: true, force: true }); }); - it('hashes the bundle with node:crypto on a runtime with no WebCrypto (Node 18)', async () => { + // With a data dir the report zip is built into a temp file, so it is hashed from its STREAM by node:crypto (incremental + // SHA-256), whether or not WebCrypto exists: WebCrypto's one-shot digest would first read the whole file into the heap. + it('hashes a streamed bundle incrementally with node:crypto on a runtime with no WebCrypto (Node 18)', async () => { vi.stubGlobal('crypto', undefined); + nodeSha256Inputs.length = 0; const client = launchLoopback(); const result = await client.logException(new Error('no webcrypto')); await client.stop(); @@ -187,11 +190,12 @@ describe('launch — loopback end-to-end', () => { expect(upload).toHaveLength(1); expect(upload[0]?.method).toBe('PUT'); expect(upload[0]?.body.subarray(0, 2).toString('latin1')).toBe('PK'); - // node:crypto hashed exactly the bytes that were PUT — the injected digest ran. - expect(nodeSha256Inputs.some((input) => input.equals(upload[0]?.body as Buffer))).toBe(true); + // node:crypto hashed exactly the bytes that were PUT, chunk by chunk. + expect(nodeSha256Inputs.length).toBeGreaterThan(0); + expect(Buffer.concat(nodeSha256Inputs).equals(upload[0]?.body as Buffer)).toBe(true); }); - it('keeps WebCrypto as the single hashing path when crypto.subtle exists (no injection)', async () => { + it('does not read a streamed bundle into WebCrypto even when crypto.subtle exists', async () => { const real = globalThis.crypto.subtle; const digests: Array<{ algorithm: string; bytes: Buffer }> = []; vi.stubGlobal('crypto', { @@ -202,6 +206,7 @@ describe('launch — loopback end-to-end', () => { }, }, }); + nodeSha256Inputs.length = 0; const client = launchLoopback(); const result = await client.logException(new Error('webcrypto present')); await client.stop(); @@ -209,11 +214,8 @@ describe('launch — loopback end-to-end', () => { expect(result.ok).toBe(true); const upload = received.filter((r) => r.url === '/upload'); expect(upload).toHaveLength(1); - // The PUT body is exactly what WebCrypto hashed: node:crypto was not substituted for it. - expect(digests).toHaveLength(1); - expect(digests[0]?.algorithm).toBe('SHA-256'); - expect(digests[0]?.bytes.equals(upload[0]?.body as Buffer)).toBe(true); - expect(nodeSha256Inputs.some((input) => input.equals(upload[0]?.body as Buffer))).toBe(false); + expect(digests).toHaveLength(0); // the bundle never went through the one-shot digest + expect(Buffer.concat(nodeSha256Inputs).equals(upload[0]?.body as Buffer)).toBe(true); }); }); diff --git a/packages/node/src/launch.test.ts b/packages/node/src/launch.test.ts index 97baf1a8..84449cf9 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -7,6 +7,7 @@ import { readFileSync, rmSync, statSync, + utimesSync, writeFileSync, } from 'node:fs'; import http, { createServer, type Server } from 'node:http'; @@ -2043,6 +2044,69 @@ describe('launch — system trace producers', () => { }); }); +describe('launch — streamed export to a temp file', () => { + it('builds the report zip into the instance export dir, uploads it, and leaves the dir empty', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bugsee-export-')); + const { fn: transport, puts } = recordingTransport(); + const { client } = launchCore( + 'tok', + baseOptions({ + transport, + clock: fixedClock, + dataDir: dir, + instanceIdentity: FIXED_INSTANCE, + carrier: {}, + }), + ); + clients.push(client); + client.captureAggregator.addEntry(new CaptureDataEntryBase('log', 1000, { msg: 'streamed' })); + await client.logException(new Error('x')); + expect(puts).toHaveLength(1); + const files = unzipSync(puts[0] as Uint8Array); + expect(strFromU8(files['logs.json'] as Uint8Array)).toContain('streamed'); + // the export file existed while the bundle was built and is gone once the upload is over + expect(existsSync(join(dir, FIXED_INSTANCE_ID, 'export'))).toBe(true); + await vi.waitFor(() => expect(readdirSync(join(dir, FIXED_INSTANCE_ID, 'export'))).toEqual([])); + }); + + it('sweeps export files a previous run of the same instance left behind, and keeps recent ones', async () => { + const dir = mkdtempSync(join(tmpdir(), 'bugsee-export-sweep-')); + const exportDir = join(dir, FIXED_INSTANCE_ID, 'export'); + mkdirSync(exportDir, { recursive: true }); + const stale = join(exportDir, 'export-stale.zip'); + const fresh = join(exportDir, 'export-fresh.zip'); + writeFileSync(stale, 'x'); + writeFileSync(fresh, 'x'); + const old = new Date(Date.now() - 2 * 3_600_000); + utimesSync(stale, old, old); + const { fn: transport } = recordingTransport(); + const { client } = launchCore( + 'tok', + baseOptions({ + transport, + clock: fixedClock, + dataDir: dir, + instanceIdentity: FIXED_INSTANCE, + carrier: {}, + }), + ); + clients.push(client); + expect(existsSync(stale)).toBe(false); + expect(existsSync(fresh)).toBe(true); + }); + + it('without an on-disk instance the report is built in memory (no export dir anywhere)', async () => { + const { fn: transport, puts } = recordingTransport(); + const { client } = launchCore( + 'tok', + baseOptions({ transport, clock: fixedClock, persist: false, carrier: {} } as never), + ); + clients.push(client); + await client.logException(new Error('x')); + expect(puts).toHaveLength(1); + }); +}); + describe('launch — capture recovery', () => { // The launch generation is fixedClock.wallNow() = 1000, so prior gens use 500 (≠ 1000). it('rebuilds + uploads a prior run’s detected incident, then sweeps its generation + marker', async () => { diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 2630e8e0..c241f55c 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -73,12 +73,15 @@ import { import { createBatchedFsChunkStorage, createCaptureRingWriter, + createFileBundleSink, createNodeBundleStore, createNodeCrashpadSessionMarkerStore, createNodeReportMarkerStore, createWorkerThreadRingWorker, httpRequest, nodeSha256Fallback, + nodeSha256Stream, + sweepStaleExports, writeFileSecure, } from '@bugsee/node-utils'; import type { ActiveSpanStore } from '@bugsee/performance'; @@ -584,6 +587,9 @@ export interface LaunchResult { const internalTagged = (transport: HttpTransport): HttpTransport => withTransportHeaders(transport, { 'x-bugsee-internal': '1' }); +/** Export files older than this at launch are leftovers of a dead run (a live export is deleted within one upload). */ +const EXPORT_FILE_MAX_AGE_MS = 60 * 60 * 1000; + /** `process.cwd()` can throw when the working directory has been deleted out from under the process. */ const safeCwd = (): string | undefined => { try { @@ -677,7 +683,13 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc // browser/edge bundles, so it cannot name `node:crypto`); where this runtime has no `crypto.subtle` // (unflagged Node 18, Electron mains on it) node supplies the `node:crypto` digest instead. Inherited by // bun, deno and electron main, which all compose this launch. - const baseUploadPipeline = createUploadPipeline({ api, uploader, sha256: nodeSha256Fallback() }); + const baseUploadPipeline = createUploadPipeline({ + api, + uploader, + sha256: nodeSha256Fallback(), + // A bundle built into a temp file is hashed from its stream (node:crypto, incremental) instead of being read whole. + sha256Stream: nodeSha256Stream, + }); // Per-instance on-disk subtree (multi-instance coexistence; design: multi-instance-disk-coexistence.md). // When file-backed, every store lives under // so several aggregators (worker_threads @@ -736,6 +748,11 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc ...(options.onError !== undefined ? { onError: options.onError } : {}), }) : undefined; + // An export file a previous launch of THIS instance identity left behind (it died between building the zip and deleting it) + // is of no use to anyone: a pinned identity reuses its subtree, so sweep it here rather than let them pile up. + if (instanceLayout !== undefined) { + sweepStaleExports(instanceLayout.exportDir, EXPORT_FILE_MAX_AGE_MS); // never throws: a missing dir or a vanished file is skipped + } const uploadPipeline = durable ?? baseUploadPipeline; // Node environment envelope, rebuilt at each report so it reflects current state. The canonical @@ -979,6 +996,11 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc : {}), ...(reportSnapshots.length > 0 ? { reportSnapshots } : {}), ...(options.fileEncoders !== undefined ? { fileEncoders: options.fileEncoders } : {}), + // With an on-disk instance the report's zip is built straight into a temp file under it (and streamed from there to + // the durable queue and the upload), so a large report never sits in the heap; without one, memory as before. + ...(instanceLayout !== undefined + ? { bundleSink: createFileBundleSink(instanceLayout.exportDir) } + : {}), ...(options.clock !== undefined ? { clock: options.clock } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), ...(options.onError !== undefined ? { onError: options.onError } : {}), From d939c61d2a8e11fd7513de9a1426be66729dd166 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 00:50:21 +0500 Subject: [PATCH 11/33] feat(browser): raise the default capture byte cap to 20 MB (30 MB with IndexedDB) after measuring the streaming export (streaming export S6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the real paths: the export's live-heap cost on a 20 MB window drops from +36 MB (memory store) / +20 MB (file store) to ~+4 MB / ~0. Browser maxDataSize defaults to 20 MB, 30 MB when the IndexedDB capture store holds the window, explicit value wins; node 50 MB and edge/worker 10 MB unchanged. Design doc section 9, PROGRESS.md closed out. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 4 +- docs/design/sdk-design.md | 2 +- docs/design/streaming-export.md | 39 ++++++++++++- packages/browser/src/launch.test.ts | 56 ++++++++++++++++++- packages/browser/src/launch.ts | 18 ++++-- .../browser/src/launch.visual-reset.test.ts | 51 +++++++++++++++++ 6 files changed, 160 insertions(+), 10 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 913cfd38..a0a922b4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -88,7 +88,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit - **Mobile-parity options (T6, `docs/design/mobile-api-alignment.md`):** `captureLogsLevel`, `captureBreadcrumbs` (JS default stays `true`), `detectHttpErrors` (`createHttpErrorDetectionProvider` in `@bugsee/capture`, mechanism `http-error`, own rate budget), `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority` / `reportDefaultErrorPriority` (applied in the client before the report handler, only to severities that were derived), and `getDefaultNetworkSanitizer()` for composing with a custom network filter. - **`client.notify(title, body?, severity?, fields?, urgent?)`** (T7, `docs/design/mobile-api-alignment.md` "T7 findings"): relays a message to the app's Slack / webhook integrations through the existing collector route `POST /v2/notifications` (app token in the body, no session, public CORS, no app-type check; verified in the appserver source). Core `createNotifyRelay` (`notify-relay.ts`): in-memory queue, batches of 50 / 256 KB, `notifyFlushDelay` window (default 0), `urgent` skips it, 2 s to 60 s backoff on transient failure, per-item `failed` retried; `BugseeApi.notify` classifies the answer; `flush()`/`stop()` deliver. Wired in browser and node. **Not persisted** (mobile persists to disk); Vercel edge / web worker / Cloudflare / WebView have no sender yet. - **Value-export parity of directly imported packages (review round 2, R2D-4):** `@bugsee/vercel-edge`, `cloudflare`, `webworker`, `webview`, `browser`, `node`, `electron` (`/main`, `/renderer`) and every framework-adapter subpath (`nextjs`/`remix`/`sveltekit`/`astro`/`nuxt` `/server`, `/edge`, `/client`) re-export the umbrella's value API (`BugseeStatus`, `LifecycleEvents`, `getStatus`, `addReportAttachment`, `getDefaultNetworkSanitizer`, `relaunch`) as thin re-exports of the same functions. `relaunch` is built per package on core `relaunchWith` so it re-runs THAT package's launch (cloudflare, bun, deno, webworker, vercel-edge, `@bugsee/nextjs/server` -> `registerServer`; the OTel provider slot is first-wins so nothing is re-attached on relaunch: the span processor registered at first launch is a stable late-bound delegate that a relaunch re-points at the new client, R3P-2; `stop()` without a relaunch drops its target and no delegate exists when OTel is off, R4H-9). The sveltekit/astro/nuxt `/edge` entries are the exception to "thin re-export" for `relaunch`: each defines its own that re-runs the launch seam remembered on the carrier at install (`rememberEdgeLaunch`/`relaunchEdge` in `@bugsee/vercel-edge`; an explicit `launch` argument wins, R4H-4). Deliberately absent: `relaunch` on `@bugsee/electron` (launchMain needs `ipcMain`; a renderer session is owned by main) and `getStatus`/`relaunch` on `@bugsee/webview` (the carrier holds only a facade). Guard: `dev-packages/packaging-guard/src/adapter-export-parity.test.ts` (add a new umbrella API to its `PUBLIC_RUNTIME_API`; exemptions carry a reason). -- **`launch(appToken, options)`** — the browser composition root (fetch/DOM analog of node's): fetch transport (internal-tagged), api/uploader/upload pipeline, browser env, in-memory store (or IndexedDB-backed when `persist:true`), gated capture providers (console→log; network umbrella, NO `node:http`; system traces; system events; user-interaction input) + detection providers, `client.launch()`. **No `process.exit` path** (the browser flushes via the pipeline/`pagehide`; `stop()` only clears the carrier). `persist:true` builds an IndexedDB durable bundle queue (crash recovery across reload — `recover()` deferred to the store's `whenReady`) + the durable IndexedDB chunk capture store (`createIdbChunkCaptureStore` in its own `bugsee-capture` db). `maxDataSize` defaults to 10 MB. **Session replay is ON by default** (2026-08-27) — parity with the iOS/Android SDKs, which record by default; `replay: false` is the opt-out and is the ONLY value on which `import('@bugsee/replay')` is never evaluated, so an errors-only integration still pays nothing for rrweb (the lazy chunk + the opt-out carry the bundle-size budget, not the default). Masking stays fail-closed on the default path (mask all text/inputs, block all media); `replay.canvas` stays opt-in. Returns the started `BugseeClient`. +- **`launch(appToken, options)`** — the browser composition root (fetch/DOM analog of node's): fetch transport (internal-tagged), api/uploader/upload pipeline, browser env, in-memory store (or IndexedDB-backed when `persist:true`), gated capture providers (console→log; network umbrella, NO `node:http`; system traces; system events; user-interaction input) + detection providers, `client.launch()`. **No `process.exit` path** (the browser flushes via the pipeline/`pagehide`; `stop()` only clears the carrier). `persist:true` builds an IndexedDB durable bundle queue (crash recovery across reload — `recover()` deferred to the store's `whenReady`) + the durable IndexedDB chunk capture store (`createIdbChunkCaptureStore` in its own `bugsee-capture` db). `maxDataSize` defaults to 20 MB (30 MB with the IndexedDB capture store; 2026-11-01). **Session replay is ON by default** (2026-08-27) — parity with the iOS/Android SDKs, which record by default; `replay: false` is the opt-out and is the ONLY value on which `import('@bugsee/replay')` is never evaluated, so an errors-only integration still pays nothing for rrweb (the lazy chunk + the opt-out carry the bundle-size budget, not the default). Masking stays fail-closed on the default path (mask all text/inputs, block all media); `replay.canvas` stays opt-in. Returns the started `BugseeClient`. ### Integration shims — `@bugsee/integration-shims` (tier-3 leaf, slice #13) No-op stand-ins for DOM-only integrations on DOM-less runtimes (design §372). `createNoopCaptureProvider`/`createNoopInterceptor` (extend `CaptureProviderBase`/`InterceptorBase`) + named shims `createViewHierarchyProviderShim`/`createBreadcrumbsProviderShim`/`createXhrInterceptorShim`. Each is a structurally-valid provider/interceptor that captures nothing and warns ONCE (`logger.warnOnce`, keyed `shim:`, message ` is a no-op on ; ignored`) on ACTIVATION (provider start / interceptor activate) — construction is side-effect-free. Logger (`Pick`) + runtime label are injected by the platform (runtime-agnostic). **`replay` is intentionally NOT a shim** (design §372: option-driven, ignored-with-warn at option resolution). Per-platform named re-exports land with the platform packages. @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Known limit, Electron:** `onVisualReset` is wired only where the browser launch builds its own store. An Electron renderer streams to the main store (default 50 MB cap), which has no control message back to the renderer's recorder, so a base shed there is only restored by the next periodic checkout (bounded by the derived cadence). Renderers of several windows also share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows. A main->renderer `checkout` control command and per-window lanes are the follow-up. **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Open (owner-approved order):** (a) streaming-export design doc (Android pattern; needs `Bundle.body`/`BundleStore`/assembler/replay-encoder contract changes); (b) streaming export with the export clamp (pre-window replay timestamps -> window start) and per-record leading-orphan trim; (c) then the default byte cap (20 MB in memory / 30 MB durable proposed, not yet confirmed). Until (b), the report start is still the earliest entry timestamp, so an anchor older than the window can stretch the timeline. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Still open:** capture-recovery and native-crash-recovery keep the buffered assembler (so it stays); the viewer's unzip of the new member order is unverified (worker-side Python `zipfile` verified); Electron main has no control channel to restart a renderer's replay (see known limit above). diff --git a/docs/design/sdk-design.md b/docs/design/sdk-design.md index 3b2c00b9..1d5b3784 100644 --- a/docs/design/sdk-design.md +++ b/docs/design/sdk-design.md @@ -1014,7 +1014,7 @@ interface BugseeOptions { // ── Recording size / duration ───────────────────────────────────── maxRecordingTime?: number; // seconds; default 60 - maxDataSize?: number; // megabytes; default 10 on browser/edge, 50 on Node/Electron — per [R:dx MAJOR-11] + maxDataSize?: number; // megabytes; default 20 on browser (30 with the IndexedDB capture store), 10 on edge/worker, 50 on Node/Electron — per [R:dx MAJOR-11], raised for the browser 2026-11-01 (streaming-export §9) maxBreadcrumbs?: number; // default 100 // ── Severity defaults ───────────────────────────────────────────── diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 4dab0a8b..d7d38fdd 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -217,7 +217,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | | **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Not streamed (residual):** capture-recovery and native-crash-recovery keep the buffered assembler (they re-use one drained capture for several bundles at launch); viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | -| **S6** | Default byte cap (20 MB memory / 30 MB durable, owner to confirm), `PROGRESS.md`, remove the buffered path where no longer reachable | gates | +| **S6** | **DONE 2026-11-01.** Default byte cap (browser: 20 MB memory / 30 MB durable; section 9), `PROGRESS.md`. The buffered assembler is NOT removed: capture-recovery and native-crash-recovery still use it | gates | Each slice ends with a multi-agent review round to zero new real findings (CLAUDE.md, implementation standards §7). @@ -269,3 +269,40 @@ parse-once/re-stringify `data` per Q2, interleaved in store order). Consequences for the design: the memory win is real (it is the whole export working set), and it is largest where it matters, on the durable backends, where today's export is the **only** place the window is ever fully in the heap. No change to the plan; S2 keeps an equivalence test against the buffered assembler and a `replay.bin` byte-format check. + +## 9. S6 results: measured live heap of the real paths, and the default byte cap (2026-11-01) + +Method: same window as section 8 (the 496 real BGSVIEWER-12 rrweb events + 20,000 logs + 3,000 network records, 20.0 MB +serialized, 20 s of capture), but through the shipped code: `drain()` -> `assembleBundle` (buffered, `encodeReplay`) against +`assembleBundleStream` over `captureExporter.stream()` with the streaming replay encoder (memory sink, and the node temp-file +sink), on a memory store and on the file-backed chunk store. Metric: the smallest `--max-old-space-size` at which the whole run +(window build + export) completes, node 24. The window build alone sets the floor (~40-44 MB), so the DIFFERENCE between rows +is the export's own live-heap cost. + +| store | export | smallest heap (MB) | export cost over the store | +| --- | --- | --- | --- | +| memory | buffered (before S2-S5) | 80 | about +36 MB (1.8x the window) | +| memory | streaming, memory sink | 48 | about +4 MB | +| memory | streaming, temp-file sink | 48 | about +4 MB | +| durable (file store) | buffered | 60 | about +20 MB (1.0x the window) | +| durable (file store) | streaming, memory sink | 44 | about +4 MB | +| durable (file store) | streaming, temp-file sink | 40 | at the floor, not measurable | + +Compressed output is 3.8-4.1 MB for the 20 MB window (about 5:1), so what the memory sink holds is the compressed bundle, +which is also what the IndexedDB bundle mirror keeps today. + +**Cap decision.** A bigger in-memory window is a real heap cost: in this bench the 20 MB window itself puts the process at +about 44 MB (roughly 2x its serialized size), so raising the memory default from 10 to 20 MB raises steady-state heap by about +that much; that is the owner's trade (20 MB, not 10, because 10 MB holds only a few seconds of rrweb DOM events). What S2-S5 change +is the REPORT-TIME copy: the same 20 MB window needed 80 MB of heap to be exported before and needs 48 MB now. Shipped: **browser +20 MB (window in memory), 30 MB (IndexedDB capture store)**, an explicit `maxDataSize` winning over both; the report's +`sdk.options` shows the value in effect. Unchanged: Node/Electron 50 MB, edge/worker 10 MB (no replay there; no BGSVIEWER-12 +exposure, no durable window). + +Limits of the measurement: the durable rows are the node FILE-backed chunk store, not the browser's IndexedDB backend, whose +read path (`snapshot()` reading parts back from the database) was not measured; and the claim "the durable window is not in the +heap" holds while IndexedDB accepts writes: records it refuses (quota, `AbortError`) are served from memory, still bounded by the +cap, so under disk pressure the heap window can be the full 30 MB. + +Residual: the browser's IndexedDB bundle store keeps whole bundle frames in an in-memory mirror (compressed, ~5:1), a few MB per +queued report, bounded by the durable queue's retention (32 bundles / 64 MiB); it was not changed. diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index c52c645a..5b2340eb 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -2816,7 +2816,7 @@ describe('launch', () => { expect(env.app?.package_id).toBe('unknown'); }); - it('reports maxDataSize (default 10 MB) in the wire-form sdk.options, and honors an override', async () => { + it('reports maxDataSize (default 20 MB without a durable store) in the wire-form sdk.options, and honors an override', async () => { const sdkOptions = async (over: Partial) => { const transport = uploadTransport(); const client = await launchTracked( @@ -2830,12 +2830,64 @@ describe('launch', () => { } ).environment.sdk.options as Record; }; - expect((await sdkOptions({}))[optionKeyToWire(BugseeOption.MaxDataSize)]).toBe(10); + expect((await sdkOptions({}))[optionKeyToWire(BugseeOption.MaxDataSize)]).toBe(20); expect((await sdkOptions({ maxDataSize: 7 }))[optionKeyToWire(BugseeOption.MaxDataSize)]).toBe( 7, ); }); + describe('maxDataSize default follows where the window lives', () => { + // The cap bounds what the window can hold. In memory it IS the heap cost; in IndexedDB the window is not in the heap, + // so it can be larger. Streaming export no longer copies the window at report time, so neither default raises the peak. + const effective = async (over: Partial): Promise => { + const transport = uploadTransport(); + const client = await launchTracked('tok', baseOptions({ transport, carrier: {}, ...over })); + await client.logException(new Error('x')); + const sent = JSON.parse( + String( + ( + transport.mock.calls.find(([url]) => + String(url).endsWith('/v2/issues'), + )?.[1] as HttpRequestOptions + ).body, + ), + ) as { environment: EnvironmentEnvelope }; + return (sent.environment.sdk.options as Record)[ + optionKeyToWire(BugseeOption.MaxDataSize) + ]; + }; + + it('is 30 MB with a durable (IndexedDB) capture store', async () => { + expect( + await effective({ persist: true, indexedDB: new IDBFactory(), locks: fakeWebLocks() }), + ).toBe(30); + }); + + it('is 20 MB when the window is held in memory (persist off, or an injected store)', async () => { + expect(await effective({ persist: false })).toBe(20); + expect( + await effective({ + persist: true, + captureStore: memStore(), + indexedDB: new IDBFactory(), + locks: fakeWebLocks(), + }), + ).toBe(20); + }); + + it('an explicit maxDataSize wins over both defaults', async () => { + expect( + await effective({ + persist: true, + indexedDB: new IDBFactory(), + locks: fakeWebLocks(), + maxDataSize: 12, + }), + ).toBe(12); + expect(await effective({ persist: false, maxDataSize: 12 })).toBe(12); + }); + }); + it('passes app identity through to the environment', async () => { const transport = uploadTransport(); const client = await launchTracked( diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 7174727b..7fdb2e9f 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -134,11 +134,16 @@ const SDK_VERSION = BUGSEE_SDK_VERSION; // the data safe is that it becomes DURABLE, and the next page load recovers it. const PAGE_HIDE_FLUSH_MS = 1000; const DEFAULT_ENDPOINT = 'https://api.bugsee.com'; -// Browser/edge capture buffer ceiling (10 MB on browser, 50 MB on Node). -const DEFAULT_MAX_DATA_SIZE_MB = 10; +// The byte cap on the rolling capture window: 20 MB while the window lives in memory, 30 MB when the IndexedDB capture store +// holds it (the window is then not in the heap, so a larger one costs quota rather than heap; if IndexedDB refuses writes the +// store keeps those records in memory, still bounded by this cap). Both used to be 10 MB, which a few seconds of rrweb DOM +// events fill (BGSVIEWER-12). A bigger in-memory window is a real heap cost (about 2x its serialized size); what the streaming +// export changes is the REPORT-TIME copy of it, which used to add another 1-2x (docs/design/streaming-export.md section 9). +const DEFAULT_MAX_DATA_SIZE_MB = 20; +const DEFAULT_DURABLE_MAX_DATA_SIZE_MB = 30; // Browser launch-option definitions = the shared cross-runtime set plus the browser's own maxDataSize -// (10 MB default vs node's 50 — its canonical identifier lives in @bugsee/protocol for wire parity). +// (20 MB default, 30 MB with the IndexedDB store, vs node's 50 — its canonical identifier lives in @bugsee/protocol for wire parity). /** Canonical identifier of the (browser-local) `replay` switch: no cross-SDK identity, so it lives here. */ /** How long persistence must hold before the replay recorder restarts for a fresh snapshot (see the disk-pressure hook). */ const REPLAY_CHECKOUT_QUIET_MS = 5000; @@ -507,7 +512,7 @@ export interface BugseeLaunchOptions { /** Rolling recording window in seconds. Default 60. */ maxRecordingTime?: number; - /** Max captured data kept in the rolling buffer, in megabytes. Default 10. */ + /** Max captured data kept in the rolling buffer, in megabytes. Default 20 (30 when the IndexedDB capture store holds it). */ maxDataSize?: number; /** * Keep the rolling capture window and the report queue in IndexedDB, so the recent past survives a @@ -785,6 +790,11 @@ async function buildLaunch( // check decided instead of the static `false` default (for an explicit or invalid value this is a no-op). resolved.canonical[PERSIST_OPTION_KEY] = persist; const durableCapture = persist && options.captureStore === undefined; + // The durable window is not in the heap: a host that did not choose a cap gets the larger durable default (and the + // report's `sdk.options` says so, like `persist` above). + if (durableCapture && !Object.hasOwn(resolved.host, BugseeOption.MaxDataSize)) { + resolved.canonical[BugseeOption.MaxDataSize] = DEFAULT_DURABLE_MAX_DATA_SIZE_MB; + } const recoverEnabled = (options.recover ?? true) && durableCapture; const coexistence = createCoexistence({ appToken, diff --git a/packages/browser/src/launch.visual-reset.test.ts b/packages/browser/src/launch.visual-reset.test.ts index 5a68aa1d..2545d4ef 100644 --- a/packages/browser/src/launch.visual-reset.test.ts +++ b/packages/browser/src/launch.visual-reset.test.ts @@ -228,3 +228,54 @@ describe('launch — a dropped replay restarts the recorder', () => { await flush(); }); }); + +describe('launch — the default byte cap is the one the store enforces', () => { + // 24 closed parts of 1 MB each: a 20 MB cap must drop the oldest, a 30 MB cap must keep all of them. + const fill = (store: ReturnType): void => { + for (let i = 0; i < 24; i++) { + store.add({ type: 'log', timestamp: now + i, serialized: `${i}:${'x'.repeat(MB)}` }); + store.tick(now + (i + 1) * 1000); + } + }; + const kept = async (over: Partial): Promise => { + registerReplay.mockReturnValue({ checkout: vi.fn() }); + const client = await launched(opts(over)); + const store = client.getService(CaptureStoreToken); + fill(store); + let count = 0; + await vi.waitFor(async () => { + count = ((await store.snapshot().drainAll()).get('log') ?? []).length; + expect(count).toBeGreaterThan(0); + }); + return count; + }; + + it('keeps about 20 MB when the window is in memory', async () => { + const n = await kept({ persist: false }); + expect(n).toBeGreaterThanOrEqual(19); + expect(n).toBeLessThanOrEqual(20); + }); + + it('keeps all 24 MB with the durable store (30 MB cap)', async () => { + expect( + await kept({ + persist: true, + indexedDB: new IDBFactory(), + locks: freeLocks, + diskProtection: false, + } as Partial), + ).toBe(24); + }); + + it('an explicit maxDataSize is the cap, durable or not', async () => { + const n = await kept({ + persist: true, + indexedDB: new IDBFactory(), + locks: freeLocks, + maxDataSize: 10, + diskProtection: false, + } as Partial); + expect(n).toBeGreaterThanOrEqual(9); + expect(n).toBeLessThanOrEqual(10); + }); +}); From 1b94667d93efa9dc900d9ae10e9540e8c62708ff Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 10:42:35 +0500 Subject: [PATCH 12/33] feat(core): recovery paths stream their rebuilt bundles (streaming export follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capture-recovery and native-crash-recovery read a prior generation's snapshot through streamReified into assembleBundleStream (one snapshot per incident/dump) instead of draining the whole generation into parsed entries. The capture exporter's stream shares the same helper. A pipeline that throws gets the body disposed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/streaming-export.md | 4 +- packages/core/src/capture-drain.test.ts | 76 ++++++- packages/core/src/capture-drain.ts | 77 ++++++- packages/core/src/capture-exporter.ts | 53 +---- packages/core/src/capture-recovery.test.ts | 4 +- packages/core/src/capture-recovery.ts | 28 +-- .../core/src/native-crash-recovery.test.ts | 23 ++- packages/core/src/native-crash-recovery.ts | 13 +- packages/core/src/recovered-bundle.test.ts | 190 ++++++++++++++++++ packages/core/src/recovered-bundle.ts | 59 ++++++ 11 files changed, 447 insertions(+), 82 deletions(-) create mode 100644 packages/core/src/recovered-bundle.test.ts create mode 100644 packages/core/src/recovered-bundle.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a0a922b4..5205e567 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Known limit, Electron:** `onVisualReset` is wired only where the browser launch builds its own store. An Electron renderer streams to the main store (default 50 MB cap), which has no control message back to the renderer's recorder, so a base shed there is only restored by the next periodic checkout (bounded by the derived cadence). Renderers of several windows also share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows. A main->renderer `checkout` control command and per-window lanes are the follow-up. **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Still open:** capture-recovery and native-crash-recovery keep the buffered assembler (so it stays); the viewer's unzip of the new member order is unverified (worker-side Python `zipfile` verified); Electron main has no control channel to restart a renderer's replay (see known limit above). +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Still open:** the viewer's unzip of the new member order is unverified (worker-side Python `zipfile` verified); Electron main has no control channel to restart a renderer's replay (see known limit above). diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index d7d38fdd..9c3cfeaa 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -215,9 +215,9 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | -| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Not streamed (residual):** capture-recovery and native-crash-recovery keep the buffered assembler (they re-use one drained capture for several bundles at launch); viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | +| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Recovery paths (closed after S6):** capture-recovery and native-crash-recovery now stream too (`recovered-bundle.ts`: `streamReified` over a per-bundle snapshot into `assembleBundleStream`; one snapshot per incident/dump instead of one shared drain, so nothing of the generation is held between bundles; a throwing pipeline gets the body disposed). Viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | -| **S6** | **DONE 2026-11-01.** Default byte cap (browser: 20 MB memory / 30 MB durable; section 9), `PROGRESS.md`. The buffered assembler is NOT removed: capture-recovery and native-crash-recovery still use it | gates | +| **S6** | **DONE 2026-11-01.** Default byte cap (browser: 20 MB memory / 30 MB durable; section 9), `PROGRESS.md`. The buffered `assembleBundle` is no longer used by any SDK path (recovery moved onto the streaming assembler after S6); it stays as the reference the streaming assembler is tested against | gates | Each slice ends with a multi-agent review round to zero new real findings (CLAUDE.md, implementation standards §7). diff --git a/packages/core/src/capture-drain.test.ts b/packages/core/src/capture-drain.test.ts index b735ad16..50f0a54a 100644 --- a/packages/core/src/capture-drain.test.ts +++ b/packages/core/src/capture-drain.test.ts @@ -1,7 +1,7 @@ import type { FileType } from '@bugsee/protocol'; import { describe, expect, it, vi } from 'vitest'; import { defaultEntryFactory } from './capture-data-entry'; -import { drainReified } from './capture-drain'; +import { drainReified, streamReified } from './capture-drain'; import type { CaptureEntryFactory, CaptureSnapshot, StoredEntry } from './contracts'; /** A fake snapshot over a fixed grouped record set that records its release. */ @@ -82,3 +82,77 @@ describe('drainReified', () => { expect(factory).toHaveBeenCalledWith('log'); }); }); + +/** A fake snapshot whose `stream()` serves the records one by one and counts releases. */ +function streamingSnapshot( + records: StoredEntry[], + range?: { start: number; end: number }, +): CaptureSnapshot & { releases: number } { + const snap = { + releases: 0, + drainAll: () => Promise.resolve(new Map()), + stream: async function* () { + yield* records; + }, + release() { + snap.releases += 1; + }, + ...(range !== undefined ? { timeRange: () => range } : {}), + } as CaptureSnapshot & { releases: number }; + return snap; +} + +describe('streamReified', () => { + it('yields the reified entries in store order and releases the snapshot once it is read through', async () => { + const snap = streamingSnapshot([stored('log', { m: 1 }), stored('network', { u: '/a' })]); + const seen: unknown[] = []; + for await (const entry of streamReified(snap, defaultEntryFactory, () => {})) { + seen.push([entry.type, entry.data]); + } + expect(seen).toEqual([ + ['log', { m: 1 }], + ['network', { u: '/a' }], + ]); + expect(snap.releases).toBe(1); + }); + + it('skips a torn record, reporting it, and keeps the rest', async () => { + const snap = streamingSnapshot([ + stored('log', { m: 1 }), + tornStored('log'), + stored('log', { m: 3 }), + ]); + const onError = vi.fn(); + const data: unknown[] = []; + for await (const entry of streamReified(snap, defaultEntryFactory, onError)) { + data.push(entry.data); + } + expect(data).toEqual([{ m: 1 }, { m: 3 }]); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('exposes the snapshot time range when the snapshot has one, and none otherwise', () => { + const range = { start: 5, end: 9 }; + expect( + streamReified(streamingSnapshot([], range), defaultEntryFactory, () => {}).timeRange, + ).toEqual(range); + expect('timeRange' in streamReified(streamingSnapshot([]), defaultEntryFactory, () => {})).toBe( + false, + ); + }); + + it('releases a snapshot that is abandoned before it was ever read, once', async () => { + const snap = streamingSnapshot([stored('log', { m: 1 })]); + const stream = streamReified(snap, defaultEntryFactory, () => {}); + await stream.return?.(); + await stream.return?.(); + expect(snap.releases).toBe(1); + }); + + it('releases the snapshot when a consumer throws into the stream', async () => { + const snap = streamingSnapshot([stored('log', { m: 1 })]); + const stream = streamReified(snap, defaultEntryFactory, () => {}); + await expect(stream.throw?.(new Error('stop'))).rejects.toThrow('stop'); + expect(snap.releases).toBe(1); + }); +}); diff --git a/packages/core/src/capture-drain.ts b/packages/core/src/capture-drain.ts index f41eacfe..c5a24bc4 100644 --- a/packages/core/src/capture-drain.ts +++ b/packages/core/src/capture-drain.ts @@ -1,5 +1,80 @@ import type { FileType } from '@bugsee/protocol'; -import type { CaptureDataEntry, CaptureEntryFactory, CaptureSnapshot } from './contracts'; +import type { + CaptureDataEntry, + CaptureEntryFactory, + CaptureEntryStream, + CaptureSnapshot, + StoredEntry, +} from './contracts'; + +function reify(record: StoredEntry, factory: CaptureEntryFactory): CaptureDataEntry { + const entry = factory(record.type); + entry.deserialize(record.serialized); + return entry; +} + +/** + * Stream a snapshot's records as reified entries, one at a time (the memory-light read; `drainReified` is the + * all-at-once one), releasing the snapshot when the read ends. Shared by the capture exporter (live store) and both + * recovery paths (a prior generation's snapshot). + * + * The snapshot is already taken, so its release must not depend on the generator ever starting: an async generator that + * was never read skips its own `finally`, and a consumer that fails before its first read would leak the frozen copy. + * `return()`/`throw()` therefore always release, once. + */ +export function streamReified( + snapshot: CaptureSnapshot, + factory: CaptureEntryFactory, + onError: (error: unknown) => void, +): CaptureEntryStream { + const records = snapshot.stream(); + let released = false; + const release = (): void => { + if (!released) { + released = true; + snapshot.release(); + } + }; + const inner = (async function* (): AsyncGenerator { + try { + for await (const record of records) { + let entry: CaptureDataEntry; + try { + entry = reify(record, factory); + } catch (error) { + // A torn/un-deserializable record (e.g. a crash's torn trailing frame): skip + report it, never let one bad + // record abort the whole export. + onError(error); + continue; + } + yield entry; + } + } finally { + release(); + } + })(); + const range = snapshot.timeRange?.(); + const stream: CaptureEntryStream = { + ...(range !== undefined ? { timeRange: range } : {}), + next: () => inner.next(), + return: async (value) => { + try { + return await inner.return(value); + } finally { + release(); + } + }, + throw: async (error) => { + try { + return await inner.throw(error); + } finally { + release(); + } + }, + [Symbol.asyncIterator]: () => stream, + }; + return stream; +} // Drain a capture snapshot into reified entries grouped by file type (the read half of CaptureExporter, // but over a snapshot we already hold rather than a live store), releasing the snapshot when done. Shared diff --git a/packages/core/src/capture-exporter.ts b/packages/core/src/capture-exporter.ts index 637a2429..3b4321fc 100644 --- a/packages/core/src/capture-exporter.ts +++ b/packages/core/src/capture-exporter.ts @@ -1,5 +1,6 @@ import type { FileType } from '@bugsee/protocol'; import { defaultEntryFactory } from './capture-data-entry'; +import { streamReified } from './capture-drain'; import type { CaptureDataEntry, CaptureEntryFactory, @@ -29,57 +30,7 @@ export function createCaptureExporter( ): CaptureExporter { return { stream(options?: { owner?: string }): CaptureEntryStream { - const snapshot = store.snapshot(options); - const records = snapshot.stream(); - // The snapshot is taken NOW (it freezes the window at the moment of the call), so its release must not depend on the - // generator below ever starting: an async generator that was never read skips its own `finally`, and a consumer - // that fails before its first read would leak the frozen copy. `return()` therefore always releases, once. - let released = false; - const release = (): void => { - if (!released) { - released = true; - snapshot.release(); - } - }; - const inner = (async function* (): AsyncGenerator { - try { - for await (const record of records) { - let entry: CaptureDataEntry; - try { - entry = reify(record, factory); - } catch (error) { - // A torn/un-deserializable record (e.g. a crash's torn trailing frame): skip + report it, - // never let one bad record abort the whole export. - onError(error); - continue; - } - yield entry; - } - } finally { - release(); - } - })(); - const range = snapshot.timeRange?.(); - const stream: CaptureEntryStream = { - ...(range !== undefined ? { timeRange: range } : {}), - next: () => inner.next(), - return: async (value) => { - try { - return await inner.return(value); - } finally { - release(); - } - }, - throw: async (error) => { - try { - return await inner.throw(error); - } finally { - release(); - } - }, - [Symbol.asyncIterator]: () => stream, - }; - return stream; + return streamReified(store.snapshot(options), factory, onError); }, async drain(options?: { owner?: string }): Promise> { diff --git a/packages/core/src/capture-recovery.test.ts b/packages/core/src/capture-recovery.test.ts index 0bbc102a..fef7266e 100644 --- a/packages/core/src/capture-recovery.test.ts +++ b/packages/core/src/capture-recovery.test.ts @@ -190,7 +190,7 @@ describe('recoverReports', () => { expect((pipe.bundles[0] as Bundle).request.email).toBe('who@x.com'); // userIdentifier → email }); - it('drains a generation ONCE for its N markers (N bundles, one snapshot)', async () => { + it('streams a generation through one snapshot per marker (N bundles, N snapshots)', async () => { const storage = createInMemoryChunkStorage(); seedGen(storage, 100, [logRecord(1, { m: 'x' })]); const backend = readBackend(storage); @@ -206,7 +206,7 @@ describe('recoverReports', () => { }); expect(pipe.enqueue).toHaveBeenCalledTimes(2); // one bundle per marker - expect(snapSpy).toHaveBeenCalledTimes(1); // but the gen's chunks were read once + expect(snapSpy).toHaveBeenCalledTimes(2); // each streams the gen's chunks itself; nothing is held between incidents }); it('never recovers the CURRENT generation', async () => { diff --git a/packages/core/src/capture-recovery.ts b/packages/core/src/capture-recovery.ts index 0b6b9836..3159173b 100644 --- a/packages/core/src/capture-recovery.ts +++ b/packages/core/src/capture-recovery.ts @@ -1,10 +1,10 @@ import { decodeRequestFromMarker } from './attachments'; -import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +import type { BundleAssemblyContext } from './bundle-assembler'; import { defaultEntryFactory } from './capture-data-entry'; -import { drainReified } from './capture-drain'; import type { ChunkBackend, FrozenPart } from './chunk-backend'; import type { CaptureEntryFactory } from './contracts'; import type { IdentifiedBundle } from './durable-upload-pipeline'; +import { assembleFromSnapshot, enqueueOwningBody } from './recovered-bundle'; import type { ReportMarker, ReportMarkerStore } from './report-marker-store'; import { type Bundle, @@ -178,23 +178,27 @@ export async function recoverReports(options: RecoverReportsOptions): Promise = new Map()) { const snapshotCalls: FrozenPart[][] = []; + const releases = { count: 0 }; const parts: PartMeta[] = [ { generation: 0, number: 0, start: 0, end: 1, byteSize: 1 } as PartMeta, ]; return { snapshotCalls, + releases, backend: { listParts: (_gen: number): PartMeta[] => parts, snapshot(frozen: readonly FrozenPart[]): CaptureSnapshot { @@ -54,9 +56,14 @@ function fakeBackend(grouped: Map = new Map()) { let released = false; return { drainAll: () => Promise.resolve(grouped), - stream: async function* () {}, + stream: async function* () { + for (const records of grouped.values()) { + yield* records; + } + }, release() { released = true; + releases.count += 1; }, get released() { return released; @@ -153,8 +160,8 @@ describe('recoverNativeCrashes', () => { expect(source.claims).toEqual(['main.dmp']); }); - it('harvests from the crashed generation and drains its capture exactly ONCE across all dumps', async () => { - const { backend, snapshotCalls } = fakeBackend(); + it('streams the crashed generation through one snapshot per dump, each released', async () => { + const { backend, snapshotCalls, releases } = fakeBackend(); const source = fakeSource([dump('a.dmp', 1), dump('b.dmp', 2)]); const { pipeline, enqueued } = fakePipeline(); @@ -168,9 +175,13 @@ describe('recoverNativeCrashes', () => { expect(result).toEqual({ harvested: 2, settled: 2, complete: true }); expect(enqueued).toHaveLength(2); - // ONE snapshot, over generation 7's parts. - expect(snapshotCalls).toHaveLength(1); - expect(snapshotCalls[0]?.[0]?.ref).toEqual({ generation: 7, number: 0 }); + // One snapshot per dump (nothing is held between them), each over generation 7's parts and each released. + expect(snapshotCalls).toHaveLength(2); + expect(snapshotCalls.map((call) => call[0]?.ref)).toEqual([ + { generation: 7, number: 0 }, + { generation: 7, number: 0 }, + ]); + expect(releases.count).toBe(2); expect(source.claims).toEqual(['a.dmp', 'b.dmp']); }); diff --git a/packages/core/src/native-crash-recovery.ts b/packages/core/src/native-crash-recovery.ts index 29e04ad6..f8f83fe2 100644 --- a/packages/core/src/native-crash-recovery.ts +++ b/packages/core/src/native-crash-recovery.ts @@ -1,10 +1,10 @@ import type { AttributeValue } from '@bugsee/types'; -import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; +import type { BundleAssemblyContext } from './bundle-assembler'; import { defaultEntryFactory } from './capture-data-entry'; -import { drainReified } from './capture-drain'; import type { ChunkBackend, FrozenPart } from './chunk-backend'; import type { CaptureEntryFactory } from './contracts'; import type { NativeCrashJson } from './crash'; +import { assembleFromSnapshot, enqueueOwningBody } from './recovered-bundle'; import { createReportingRequest } from './reporting'; import { isUploadSettled, type UploadPipeline } from './transport'; @@ -104,13 +104,13 @@ export async function recoverNativeCrashes( } const base = options.context(); - // Drain the crashed generation's capture ONCE — every dump of this session shares the same recording. + // The crashed generation's capture is streamed through a snapshot per dump (every dump of this session shares the + // same recording), so nothing of it is held between dumps. const parts = await backend.listParts(marker.generation); const frozen: FrozenPart[] = parts.map((part) => ({ ref: { generation: marker.generation, number: part.number }, count: Number.MAX_SAFE_INTEGER, // recover the whole part })); - const captured = await drainReified(backend.snapshot(frozen), entryFactory, onError); for (const dump of dumps) { try { @@ -129,12 +129,13 @@ export async function recoverNativeCrashes( }, options.generateId, ); - const bundle = assembleBundle(request, captured, { + const bundle = await assembleFromSnapshot(request, backend.snapshot(frozen), entryFactory, { ...base, + onError: base.onError ?? onError, attributes: marker.attributes, userIdentifier: marker.userIdentifier, }); - const result = await uploadPipeline.enqueue(bundle); + const result = await enqueueOwningBody(uploadPipeline, bundle); if (isUploadSettled(result)) { // Delivered, or PERMANENTLY REFUSED — settled either way, so the dump is claimed and the // session's marker becomes clearable. Gating on `ok` alone (as this leg used to) meant a diff --git a/packages/core/src/recovered-bundle.test.ts b/packages/core/src/recovered-bundle.test.ts new file mode 100644 index 00000000..f9358b5b --- /dev/null +++ b/packages/core/src/recovered-bundle.test.ts @@ -0,0 +1,190 @@ +import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { describe, expect, it, vi } from 'vitest'; +import type { BundleAssemblyContext } from './bundle-assembler'; +import { createMemoryBody, isBundleBody } from './bundle-body'; +import { createMemorySink } from './bundle-sink'; +import { defaultEntryFactory } from './capture-data-entry'; +import type { CaptureSnapshot, StoredEntry } from './contracts'; +import { assembleFromSnapshot, enqueueOwningBody } from './recovered-bundle'; +import { createReportingRequest } from './reporting'; +import type { Bundle } from './transport'; + +const env: EnvironmentEnvelope = { + platform: { type: 'web', version: '1' }, + runtime: { type: 'web', version: '' }, + sdk: { version: '1.0.0', type: 'javascript' }, +}; +const context = (over: Partial = {}): BundleAssemblyContext => ({ + appToken: 'tok', + environment: env, + attributes: {}, + clock: { wallNow: () => 2_000, monotonicNow: () => 0 }, + fileName: () => 'fixed.bundle.zip', + ...over, +}); +const stored = (type: FileType, timestamp: number, data: unknown): StoredEntry => + ({ type, timestamp, serialized: JSON.stringify({ timestamp, data }) }) as StoredEntry; +const snapshotOf = (records: StoredEntry[]): CaptureSnapshot & { releases: number } => { + const snap = { + releases: 0, + drainAll: () => Promise.resolve(new Map()), + stream: async function* () { + yield* records; + }, + release() { + snap.releases += 1; + }, + } as CaptureSnapshot & { releases: number }; + return snap; +}; +const request = () => + createReportingRequest({ type: 'error', source: { type: 'error' }, summary: 's' } as never); + +describe('assembleFromSnapshot', () => { + it('builds the bundle from the snapshot as plain bytes and releases the snapshot', async () => { + const snap = snapshotOf([stored('log', 1_000, { m: 1 }), stored('log', 1_500, { m: 2 })]); + const bundle = await assembleFromSnapshot(request(), snap, defaultEntryFactory, context()); + expect(bundle.body).toBeInstanceOf(Uint8Array); + const files = unzipSync(bundle.body as Uint8Array); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual([{ m: 1 }, { m: 2 }]); + expect(bundle.fileName).toBe('fixed.bundle.zip'); + expect(snap.releases).toBe(1); + }); + + it('routes a torn record to the context onError and keeps the rest', async () => { + const onError = vi.fn(); + const snap = snapshotOf([ + stored('log', 1_000, { m: 1 }), + { type: 'log', timestamp: 1_100, serialized: 'NOT-JSON' } as StoredEntry, + ]); + const bundle = await assembleFromSnapshot( + request(), + snap, + defaultEntryFactory, + context({ onError }), + ); + expect(onError).toHaveBeenCalledTimes(1); + const files = unzipSync(bundle.body as Uint8Array); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toHaveLength(1); + }); + + it('tolerates a torn record when the context has no onError', async () => { + const snap = snapshotOf([ + stored('log', 1_000, { m: 1 }), + { type: 'log', timestamp: 1_100, serialized: 'NOT-JSON' } as StoredEntry, + ]); + const bundle = await assembleFromSnapshot(request(), snap, defaultEntryFactory, context()); + const files = unzipSync(bundle.body as Uint8Array); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual([{ m: 1 }]); + }); + + it('hands a non-memory sink body on for the upload to own', async () => { + const body = createMemoryBody([new Uint8Array([1, 2, 3])]); + const sink = { + open: () => + Promise.resolve({ + write: () => Promise.resolve(), + finish: () => Promise.resolve({ ...body, peek: () => undefined }), + abort: () => Promise.resolve(), + }), + }; + const bundle = await assembleFromSnapshot( + request(), + snapshotOf([]), + defaultEntryFactory, + context({ sink }), + ); + expect(isBundleBody(bundle.body)).toBe(true); + }); + + it('releases the snapshot and rethrows when the build fails', async () => { + const snap = snapshotOf([stored('log', 1_000, { m: 1 })]); + const failing = { + open: () => + Promise.resolve({ + write: () => Promise.reject(new Error('disk full')), + finish: () => Promise.reject(new Error('unused')), + abort: () => Promise.resolve(), + }), + }; + await expect( + assembleFromSnapshot(request(), snap, defaultEntryFactory, context({ sink: failing })), + ).rejects.toThrow('disk full'); + expect(snap.releases).toBe(1); + }); + + it('exposes nothing about the sink default: the same sink instance is reusable', async () => { + const sink = createMemorySink(); + const one = await assembleFromSnapshot( + request(), + snapshotOf([]), + defaultEntryFactory, + context({ sink }), + ); + const two = await assembleFromSnapshot( + request(), + snapshotOf([]), + defaultEntryFactory, + context({ sink }), + ); + expect(one.body).toBeInstanceOf(Uint8Array); + expect(two.body).toBeInstanceOf(Uint8Array); + }); +}); + +describe('enqueueOwningBody', () => { + const bundleWith = (dispose: () => void): Bundle => + ({ + request: {} as never, + fileName: 'f', + body: { ...createMemoryBody([new Uint8Array(1)]), dispose }, + }) as Bundle; + + it('returns the pipeline result and leaves disposal to the pipeline', async () => { + const dispose = vi.fn(); + const result = { ok: true } as never; + const out = await enqueueOwningBody( + { enqueue: () => Promise.resolve(result) }, + bundleWith(dispose), + ); + expect(out).toBe(result); + expect(dispose).not.toHaveBeenCalled(); + }); + + it('disposes the body and rethrows when the pipeline throws', async () => { + const dispose = vi.fn(); + await expect( + enqueueOwningBody({ enqueue: () => Promise.reject(new Error('boom')) }, bundleWith(dispose)), + ).rejects.toThrow('boom'); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('does not let a failing dispose replace the original failure', async () => { + await expect( + enqueueOwningBody( + { enqueue: () => Promise.reject(new Error('boom')) }, + bundleWith(() => { + throw new Error('dispose failed'); + }), + ), + ).rejects.toThrow('boom'); + }); + + it('does not let a rejecting dispose surface as an unhandled rejection', async () => { + const dispose = vi.fn(() => Promise.reject(new Error('dispose rejected'))); + await expect( + enqueueOwningBody({ enqueue: () => Promise.reject(new Error('boom')) }, bundleWith(dispose)), + ).rejects.toThrow('boom'); + await Promise.resolve(); + await Promise.resolve(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('is fine with a plain-bytes body', async () => { + const bundle = { request: {} as never, fileName: 'f', body: new Uint8Array(1) } as Bundle; + await expect( + enqueueOwningBody({ enqueue: () => Promise.reject(new Error('boom')) }, bundle), + ).rejects.toThrow('boom'); + }); +}); diff --git a/packages/core/src/recovered-bundle.ts b/packages/core/src/recovered-bundle.ts new file mode 100644 index 00000000..0112fc7d --- /dev/null +++ b/packages/core/src/recovered-bundle.ts @@ -0,0 +1,59 @@ +import type { BundleAssemblyContext } from './bundle-assembler'; +import { isBundleBody } from './bundle-body'; +import { assembleBundleStream } from './bundle-stream-assembler'; +import { streamReified } from './capture-drain'; +import type { CaptureEntryFactory, CaptureSnapshot } from './contracts'; +import type { ReportingRequest } from './reporting'; +import type { Bundle, UploadPipeline, UploadResult } from './transport'; + +// The two recovery paths (capture-recovery, native-crash-recovery) rebuild a bundle from a PRIOR generation's preserved +// chunks. They used to drain the whole generation into parsed entries and assemble from that; this reads the generation's +// snapshot one entry at a time into the streaming assembler instead (docs/design/streaming-export.md), so a recovered +// window costs what a live export costs. + +/** + * Build a bundle from `snapshot`, streaming. The snapshot is released when the read ends, on success and failure alike; + * a torn record is skipped and reported to `context.onError`, never allowed to fail the generation's recovery. + * + * The default (memory) sink hands out plain bytes, as every assembler did; any other sink's body is passed on for the + * upload pipeline to own. + */ +export async function assembleFromSnapshot( + request: ReportingRequest, + snapshot: CaptureSnapshot, + factory: CaptureEntryFactory, + context: BundleAssemblyContext, +): Promise { + const assembled = await assembleBundleStream( + request, + streamReified(snapshot, factory, context.onError ?? ((): void => {})), + context, + ); + return { + request: assembled.request, + fileName: assembled.fileName, + body: assembled.body.peek() ?? assembled.body, + }; +} + +/** + * Hand `bundle` to the pipeline, which owns (and disposes) its body from `enqueue` on. A pipeline that THROWS never took + * it, so nothing else will release a file/Blob-backed body: do it here. + */ +export async function enqueueOwningBody( + pipeline: Pick, + bundle: Bundle, +): Promise { + try { + return await pipeline.enqueue(bundle); + } catch (error) { + if (isBundleBody(bundle.body)) { + try { + void Promise.resolve(bundle.body.dispose()).catch(() => {}); + } catch { + // releasing must never change the outcome + } + } + throw error; + } +} From b80910c722a2acbbd8e7ce5b84f1f8d910d774c5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 10:43:48 +0500 Subject: [PATCH 13/33] test(instrumentation): the retired-event-name guard skips gitignored agent worktrees and generated reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../instrumentation-tests/test/event-names.guard.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/instrumentation-tests/test/event-names.guard.test.ts b/packages/instrumentation-tests/test/event-names.guard.test.ts index b09afa4e..5f2c1e42 100644 --- a/packages/instrumentation-tests/test/event-names.guard.test.ts +++ b/packages/instrumentation-tests/test/event-names.guard.test.ts @@ -26,10 +26,12 @@ const SKIP_DIRS = new Set([ 'node_modules', 'dist', '.git', + '.claude', // agent worktrees: gitignored whole-repo copies, not the repo '.turbo', '.local-registry', '.changeset', // release notes are history 'coverage', + 'reports', // gitignored generated output (Stryker) '.next', '.nuxt', '.output', @@ -73,15 +75,18 @@ describe('retired system-event names', () => { expect(offendersIn(ROOT).map((f) => relative(ROOT, f))).toEqual([]); }); - it('probe: a retired name in a scanned file IS reported, in a skipped dir or unscanned type it is not', () => { + it('probe: a retired name in a scanned file IS reported, in a skipped dir (node_modules, an agent worktree) or unscanned type it is not', () => { const dir = mkdtempSync(join(tmpdir(), 'event-names-guard-')); try { mkdirSync(join(dir, 'src')); mkdirSync(join(dir, 'node_modules')); + mkdirSync(join(dir, '.claude', 'worktrees', 'agent-x'), { recursive: true }); writeFileSync(join(dir, 'src', 'a.vue'), `emit('${RETIRED[0]}')`); writeFileSync(join(dir, 'src', 'clean.ts'), 'emit("OrientationChanged")'); writeFileSync(join(dir, 'src', 'a.png'), RETIRED[1] as string); writeFileSync(join(dir, 'node_modules', 'b.ts'), RETIRED[2] as string); + // agent worktrees are whole copies of the repo (and are gitignored), not part of it + writeFileSync(join(dir, '.claude', 'worktrees', 'agent-x', 'c.md'), RETIRED[3] as string); expect(offendersIn(dir).map((f) => relative(dir, f))).toEqual([join('src', 'a.vue')]); } finally { rmSync(dir, { recursive: true, force: true }); From 25bf470003d2020c9231a4ecd3e394d98c49d16b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 10:50:40 +0500 Subject: [PATCH 14/33] feat(electron,core,node,browser): main tells renderers to restart their replay when its store sheds every base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electron main owns the byte-capped store the renderers stream into, but its onVisualReset had nobody to tell, so a shed base was only restored by the next periodic checkout. New control command `checkout` (main -> renderer): node launch gains onVisualReset, launchMain gates it with the shared createVisualResetGate (extracted from the browser launch, same backoff) and broadcasts checkout, the renderer restarts its recorder through the browser launch's new onReplayRestart hook. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 4 +- docs/design/electron.md | 3 +- packages/browser/src/launch.ts | 38 ++++---- .../browser/src/launch.visual-reset.test.ts | 40 ++++++++ packages/bugsee/src/relaunch-options.test.ts | 2 + packages/core/src/index.ts | 3 +- packages/core/src/visual-reset-gate.test.ts | 94 +++++++++++++++++++ packages/core/src/visual-reset-gate.ts | 37 ++++++++ packages/electron/src/launch-main.test.ts | 72 ++++++++++++++ packages/electron/src/launch-main.ts | 23 +++++ packages/electron/src/launch-renderer.test.ts | 32 +++++++ packages/electron/src/launch-renderer.ts | 12 +++ packages/electron/src/main-control.test.ts | 20 ++++ packages/electron/src/main-control.ts | 10 +- packages/electron/src/protocol.test.ts | 4 + packages/electron/src/protocol.ts | 5 +- .../electron/src/renderer-control.test.ts | 14 +++ packages/electron/src/renderer-control.ts | 8 ++ packages/node/src/launch.data-size.test.ts | 24 +++++ packages/node/src/launch.ts | 7 ++ 20 files changed, 427 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/visual-reset-gate.test.ts create mode 100644 packages/core/src/visual-reset-gate.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5205e567..98bb9a7c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1453,6 +1453,6 @@ Verified against current majors, all green with no adapter code changes: `@bugse - `disk-pressure-backend` counts one tick per store tick (distinct `end`), not one per closed part, so the probe backoff / flap window do not run twice as fast while replay is recorded. - Browser launch: `onVisualReset` -> recorder `checkout()` with exponential backoff (5 s doubling to 5 min, reset after 10 min quiet) so an oversized snapshot cannot loop; replay checkout cadence = `clamp(window/2, 5 s, 60 s)`; an explicit positive `replay.checkoutEveryNms` wins (undefined/0/NaN/non-numbers do not). **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. -**Known limit, Electron:** `onVisualReset` is wired only where the browser launch builds its own store. An Electron renderer streams to the main store (default 50 MB cap), which has no control message back to the renderer's recorder, so a base shed there is only restored by the next periodic checkout (bounded by the derived cadence). Renderers of several windows also share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows. A main->renderer `checkout` control command and per-window lanes are the follow-up. +**Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Still open:** the viewer's unzip of the new member order is unverified (worker-side Python `zipfile` verified); Electron main has no control channel to restart a renderer's replay (see known limit above). +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **Still open:** per-window visual lanes in the Electron main (see above); the IndexedDB read path of the streaming export is unmeasured. diff --git a/docs/design/electron.md b/docs/design/electron.md index 0fdc8287..10e538ec 100644 --- a/docs/design/electron.md +++ b/docs/design/electron.md @@ -124,11 +124,12 @@ backend symbolicates. App native-module symbols upload at build time via `bugsee | field | on | meaning | |---|---|---| - | `c` | all | the command: `pause`, `resume`, `flush`, `stop`, `session`, `blackout-start`, `blackout-end`, `window-key` | + | `c` | all | the command: `pause`, `resume`, `flush`, `stop`, `session`, `blackout-start`, `blackout-end`, `window-key`, `checkout` | | `sid` | `session` | the owner's session id | | `bo` | `session` | main's blackout state at handshake time (boolean; a renderer that loads mid-blackout starts dark). Always stated by `launchMain` | | `ho` | `session` | the trace names **main writes itself**, so the renderer must not write them (one writer per name per session; spec `platforms/javascript.md` "Host-owned traces"): `displays` with a `screen`, `capture` with pixel video, `ram_js_heap_{total,used,free}` while a producer named `v8-heap` (`V8_HEAP_PRODUCER_NAME`) is in main's set (the default node set, or a caller set from `createNodeSystemTraceProducers()`); `[]` when main writes no traces. At most 64 names of at most 64 characters; a malformed array is ignored whole. A renderer **never** writes `displays`, `capture` or `ram_js_heap_*`, whatever `ho` says (`ho` can only add names; with no screen, no pixel video or no heap producer the session has none). The heap is detected by the producer name `v8-heap` (`V8_HEAP_PRODUCER_NAME`): that name is the contract, and a custom producer that borrows it without writing the heap makes main declare a heap nobody writes. Absent: the renderer keeps its built-in default (fail closed). Applied to the very next trace value | | `kw` | `session`, `window-key` | whether the receiving renderer's window is the session's **key window** (spec "Several windows in one session": only the key window writes the per-window traces `url`, `route`, `view`, `orientation`, `app_state`, `fps`, `storage_estimate`, `main_thread_*`, `user_idle_state`). Boolean. Absent on `session`: main cannot resolve windows, so the renderer is key (fail open) | + | `checkout` | main to renderer | sent to every live renderer when main's store shed every replay base under its byte cap (paced by `createVisualResetGate`); the renderer restarts its replay recorder for a fresh full snapshot. No payload | | `window-key` | main to renderer | sent when the answer **changes** for that renderer (the flag rides in `kw`; a missing flag reads as not key); the renderer suppresses or releases the per-window names and the provider re-records a held value (`url`) when the window becomes key | **Key-window rule and its limits** (`main-control.ts`, `launch-main.ts`). The key window is the focused `BrowserWindow`, else the most recently focused, else the first created, i.e. the LOWEST window `id` among the live ones, a visible one preferred (`application-tracker.ts`; Electron's `getAllWindows()` is not in creation order: newest first on 44.9.3, unordered after a close; ids only increase). A renderer is key when its owning window (`BrowserWindow.fromWebContents`, else `webContents.getOwnerBrowserWindow()`) is the key window. Fail-open cases, where the renderer stays key rather than being silenced: its window cannot be resolved, the window is **not in `BrowserWindow.getAllWindows()`** (a view hosted in a `BaseWindow`: Electron resolves the BaseWindow's id but never lists it nor fires `browser-window-created` for it), the window list cannot be read, or the probe throws. When the tracker's key window hosts no registered renderer: the key falls back to the last window that did (while it still does). If the tracker's key IS the window whose renderer just left (a closing window's webContents fires `destroyed` before its `closed`; a crash fires `render-process-gone`), or no window has hosted a renderer yet (startup), the key is HELD there (nobody writes, nobody flashes in) until the tracker publishes its own next choice, or for at most 3 s (a crash with no reload never gets a `closed`), after which the window of the earliest registered renderer takes it. A re-evaluation reads the key once, the window list once and `fromWebContents` once per renderer. **Limit:** a key window that still runs the page but no longer the SDK (the page navigated to a document without Bugsee, or called `client.stop()`) keeps its renderer registered, because Electron keeps the same webContents across navigation and the renderer sends no goodbye; while that window stays key, the other windows' per-window traces stay silenced until the key moves (focus, show, hide, close) or the window's SDK starts again (its hello re-registers). A `bye` on stop/pagehide, or a `did-navigate` re-evaluation, would remove the limit and is not implemented. **Known limit (open spec question):** one launch per *window* is assumed. A `BrowserWindow` with its own contents plus several `WebContentsView`s gives every instrumented contents `kw:true` (the same owner), so their `url` values interleave. A cheap rule exists and is **not implemented** (needs an owner decision): within one window the window's own `webContents` is key, else the most recently focused contents (`webContents.isFocused()` / `focus` events). `BaseWindow` windows are not tracked (no listing, no creation event), so they are always key. diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 7fdb2e9f..9084904e 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -48,6 +48,7 @@ import { createServiceContainer, createSystemClock, createUploadPipeline, + createVisualResetGate, type DiskPressureState, DiskPressureToken, defineService, @@ -150,9 +151,6 @@ const REPLAY_CHECKOUT_QUIET_MS = 5000; /** Longest replay checkout cadence (rrweb default) and the shortest we derive from a short window. */ const REPLAY_CHECKOUT_MAX_MS = 60_000; const REPLAY_CHECKOUT_MIN_MS = 5000; -/** Visual-reset restarts back off (x2 per consecutive reset) up to this, and start over after this much quiet. */ -const REPLAY_RESET_MAX_INTERVAL_MS = 300_000; -const REPLAY_RESET_QUIET_RESET_MS = 600_000; const REPLAY_OPTION_KEY = 'com.bugsee.option.replay.enabled'; /** Canonical identifier of the (browser-local) `persist` switch: no cross-SDK identity, so it lives here. */ const PERSIST_OPTION_KEY = 'com.bugsee.option.browser.persist'; @@ -558,6 +556,12 @@ export interface BugseeLaunchOptions { * browser leaves it unset. */ hostOwnedTraces?: readonly string[] | TraceSuppression; + /** + * For a host whose store lives elsewhere (the Electron renderer streams into main's): called once at launch with a function + * that restarts the replay recorder for a fresh full snapshot (a no-op until the recorder exists, or when there is none). + * The host decides WHEN (it paces its own restarts); a standalone browser needs none, its own store restarts the recorder. + */ + onReplayRestart?: (restart: () => void) => void; /** Time source. Default the system clock (createClient's default). */ clock?: Clock; /** Scheduler for the capture-store tick + system-traces sampling. Default global timers. */ @@ -867,25 +871,21 @@ async function buildLaunch( // Set once the replay recorder exists. The store calls it when the byte cap forced every replay base out, so the // recorder takes a fresh FullSnapshot; rate-limited so an oversized snapshot cannot loop snapshot -> drop -> snapshot. let restartReplay: (() => void) | undefined; - let lastReplayRestart = Number.NEGATIVE_INFINITY; - let replayRestartInterval = REPLAY_CHECKOUT_QUIET_MS; + try { + options.onReplayRestart?.((): void => restartReplay?.()); + } catch (error) { + reportGuarded(options.onError, error); // a host callback must never fail the launch + } const storeBounds = { maxRecordingTimeMs: maxRecordingTime * 1000, maxDataSizeBytes: maxDataSize * 1024 * 1024, - onVisualReset: (): void => { - const at = clock.monotonicNow(); - if (restartReplay === undefined || at - lastReplayRestart < replayRestartInterval) { - return; - } - // Resets that keep coming mean one snapshot does not fit the cap: back off (doubling, up to 5 min) instead of - // snapshotting the whole DOM every few seconds; a long quiet spell starts over from the base interval. - replayRestartInterval = - at - lastReplayRestart > REPLAY_RESET_QUIET_RESET_MS - ? REPLAY_CHECKOUT_QUIET_MS - : Math.min(replayRestartInterval * 2, REPLAY_RESET_MAX_INTERVAL_MS); - lastReplayRestart = at; - restartReplay(); - }, + // Resets that keep coming mean one snapshot does not fit the cap: the gate backs off (doubling, up to 5 min) instead + // of snapshotting the whole DOM every few seconds, and a reset before the recorder exists spends none of its budget. + onVisualReset: createVisualResetGate({ + now: () => clock.monotonicNow(), + ready: () => restartReplay !== undefined, + restart: () => restartReplay?.(), + }), ...(options.clock !== undefined ? { clock: options.clock } : {}), }; const clock = options.clock ?? createSystemClock(); diff --git a/packages/browser/src/launch.visual-reset.test.ts b/packages/browser/src/launch.visual-reset.test.ts index 2545d4ef..110a8508 100644 --- a/packages/browser/src/launch.visual-reset.test.ts +++ b/packages/browser/src/launch.visual-reset.test.ts @@ -229,6 +229,46 @@ describe('launch — a dropped replay restarts the recorder', () => { }); }); +describe('launch — a host can restart the recorder (onReplayRestart)', () => { + it('hands the host a restart that asks the recorder for a fresh base, with no backoff of its own', async () => { + const checkout = vi.fn(); + registerReplay.mockReturnValue({ checkout }); + let restart: (() => void) | undefined; + await launched( + opts({ onReplayRestart: (fn: () => void) => (restart = fn) } as Partial), + ); + await flush(); + expect(restart).toBeTypeOf('function'); + restart?.(); + restart?.(); // the host owns the pacing (the Electron main gates it): every call is a checkout + expect(checkout).toHaveBeenCalledTimes(2); + }); + + it('is a no-op before the recorder exists and when there is none (DOM-less host)', async () => { + registerReplay.mockReturnValue(undefined); + let restart: (() => void) | undefined; + await launched( + opts({ onReplayRestart: (fn: () => void) => (restart = fn) } as Partial), + ); + await flush(); + expect(() => restart?.()).not.toThrow(); + }); + + it('a throwing host callback is reported and never fails the launch', async () => { + registerReplay.mockReturnValue({ checkout: vi.fn() }); + const onError = vi.fn(); + await launched( + opts({ + onError, + onReplayRestart: () => { + throw new Error('host broke'); + }, + } as Partial), + ); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'host broke' })); + }); +}); + describe('launch — the default byte cap is the one the store enforces', () => { // 24 closed parts of 1 MB each: a 20 MB cap must drop the oldest, a 30 MB cap must keep all of them. const fill = (store: ReturnType): void => { diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index d95d35be..d2d25133 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -146,7 +146,9 @@ const RESET_OPTIONS: readonly string[] = [ 'gpuInfoType', 'injectTraceMeta', 'instanceIdentity', + 'onReplayRestart', // wired by the host (the Electron renderer) to its own client; a relaunch's host re-passes it 'onSpanProcessor', + 'onVisualReset', // wired by the host (the Electron main) to its control channel, released with the client 'setupOtelProvider', 'bridge', 'captureBreadcrumbs', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index caa205bf..5a131a4d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -81,7 +81,7 @@ export { type OptionGate, } from './capture-coordinator'; export { CaptureDataEntryBase, defaultEntryFactory } from './capture-data-entry'; -export { drainReified } from './capture-drain'; +export { drainReified, streamReified } from './capture-drain'; export { createCaptureExporter } from './capture-exporter'; export { CaptureProviderBase } from './capture-provider-base'; export { @@ -380,3 +380,4 @@ export { type PipelineOutcome, type UploadPipelineOptions, } from './upload-pipeline'; +export { createVisualResetGate, type VisualResetGateOptions } from './visual-reset-gate'; diff --git a/packages/core/src/visual-reset-gate.test.ts b/packages/core/src/visual-reset-gate.test.ts new file mode 100644 index 00000000..afd82f96 --- /dev/null +++ b/packages/core/src/visual-reset-gate.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createVisualResetGate } from './visual-reset-gate'; + +const make = (over: { ready?: () => boolean } = {}) => { + let now = 0; + const restart = vi.fn(); + const gate = createVisualResetGate({ now: () => now, restart, ...over }); + return { gate, restart, at: (t: number) => (now = t) }; +}; + +describe('createVisualResetGate', () => { + it('restarts on the first reset', () => { + const { gate, restart, at } = make(); + at(0); + gate(); + expect(restart).toHaveBeenCalledTimes(1); + }); + + it('does not restart again within the 5 s quiet period, and does once it has passed', () => { + const { gate, restart, at } = make(); + at(0); + gate(); + at(4_999); + gate(); + expect(restart).toHaveBeenCalledTimes(1); + at(5_000); + gate(); + expect(restart).toHaveBeenCalledTimes(2); + }); + + it('backs off (doubling, capped at 5 min) while resets keep coming', () => { + const { gate, restart, at } = make(); + const times = [0, 5_000, 15_000, 35_000, 75_000, 155_000, 315_000, 615_000]; + const calls: number[] = []; + for (const t of times) { + at(t); + const before = restart.mock.calls.length; + gate(); + calls.push(restart.mock.calls.length - before); + } + // each gap equals the interval in force (5, 10, 20, 40, 80, 160, 300 s: capped) + expect(calls).toEqual([1, 1, 1, 1, 1, 1, 1, 1]); + at(615_000 + 299_999); + gate(); + expect(restart).toHaveBeenCalledTimes(8); + at(615_000 + 300_000); + gate(); + expect(restart).toHaveBeenCalledTimes(9); + }); + + it('starts over at 5 s after a quiet spell of more than ten minutes, but not after exactly ten', () => { + const { gate, restart, at } = make(); + at(0); + gate(); + at(5_000); + gate(); // interval is now 10 s + at(5_000 + 600_000); // exactly ten minutes: not yet "long enough" + gate(); + expect(restart).toHaveBeenCalledTimes(3); + at(5_000 + 600_000 + 9_999); // still inside the doubled (20 s) interval + gate(); + expect(restart).toHaveBeenCalledTimes(3); + at(5_000 + 600_000 + 600_001 + 10_000); + gate(); // long quiet: back to 5 s + expect(restart).toHaveBeenCalledTimes(4); + at(5_000 + 600_000 + 600_001 + 10_000 + 5_000); + gate(); + expect(restart).toHaveBeenCalledTimes(5); + }); + + it('does not spend the budget on a reset that arrives while not ready', () => { + let ready = false; + const { gate, restart, at } = make({ ready: () => ready }); + at(0); + gate(); + expect(restart).not.toHaveBeenCalled(); + ready = true; + at(1); + gate(); // not held back by the earlier ignored reset + expect(restart).toHaveBeenCalledTimes(1); + }); + + it('contains nothing a throwing restart could leave half-done: the budget is spent before it runs', () => { + const restart = vi.fn(() => { + throw new Error('boom'); + }); + let now = 0; + const gate = createVisualResetGate({ now: () => now, restart }); + expect(() => gate()).toThrow('boom'); + now = 1; + expect(() => gate()).not.toThrow(); // inside the quiet period: skipped, not retried + expect(restart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/visual-reset-gate.ts b/packages/core/src/visual-reset-gate.ts new file mode 100644 index 00000000..5245792e --- /dev/null +++ b/packages/core/src/visual-reset-gate.ts @@ -0,0 +1,37 @@ +// Rate-limits "the store lost every visual base, restart the recorder". A restart costs a full-DOM snapshot; if one +// snapshot does not fit the byte cap, every restart is followed by another reset, so resets that keep coming must back off +// instead of snapshotting every few seconds. Shared by the browser launch (its own store) and the Electron main (whose +// store the renderers stream into, and which tells them to restart over the control channel). + +/** The shortest gap between two restarts. */ +const QUIET_MS = 5000; +/** The longest gap the backoff reaches. */ +const MAX_INTERVAL_MS = 300_000; +/** A quiet spell longer than this starts the backoff over. */ +const QUIET_RESET_MS = 600_000; + +export interface VisualResetGateOptions { + /** A monotonic millisecond clock. */ + now(): number; + /** Ask the recorder(s) for a fresh base. */ + restart(): void; + /** False while there is nothing to restart (no recorder yet): such a reset neither restarts nor uses up the budget. Default always ready. */ + ready?(): boolean; +} + +/** The function to call on every visual reset; it restarts the recorder at most as often as the backoff allows. */ +export function createVisualResetGate(options: VisualResetGateOptions): () => void { + let last = Number.NEGATIVE_INFINITY; + let interval = QUIET_MS; + return (): void => { + const at = options.now(); + if (options.ready?.() === false || at - last < interval) { + return; + } + // Resets that keep coming mean one snapshot does not fit the cap: back off (doubling) instead of snapshotting the whole + // DOM every few seconds; a long quiet spell starts over from the base interval. + interval = at - last > QUIET_RESET_MS ? QUIET_MS : Math.min(interval * 2, MAX_INTERVAL_MS); + last = at; + options.restart(); + }; +} diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index d92f0850..14d5041b 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -157,6 +157,78 @@ describe('launchMain', () => { expect(f.stop).toHaveBeenCalledTimes(1); // original node stop still runs }); + describe('a visual reset in the main store (every replay base shed under the byte cap)', () => { + const setup = (over: Record = {}) => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + let now = 0; + launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + clock: { wallNow: () => now, monotonicNow: () => now }, + ...over, + } as never); + const r = fakeSender(); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r }, encodeHello()); + r.sent.length = 0; + const reset = (): void => + (f.received?.options as unknown as { onVisualReset: () => void }).onVisualReset(); + return { f, r, reset, at: (t: number) => (now = t) }; + }; + + it('asks every renderer to restart its replay recorder', () => { + const { r, reset } = setup(); + reset(); + expect(r.sent.map((m) => decodeControl(m.raw)?.command)).toEqual(['checkout']); + }); + + it('is paced: a reset inside the quiet period asks nothing, a later one asks again (backing off)', () => { + const { r, reset, at } = setup(); + reset(); + at(1_000); + reset(); + expect(r.sent).toHaveLength(1); + at(5_000); + reset(); + expect(r.sent).toHaveLength(2); + at(10_000); // the interval doubled to 10 s + reset(); + expect(r.sent).toHaveLength(2); + at(15_000); + reset(); + expect(r.sent).toHaveLength(3); + }); + + it("still calls the caller's own onVisualReset", () => { + const own = vi.fn(); + const { reset } = setup({ onVisualReset: own }); + reset(); + expect(own).toHaveBeenCalledTimes(1); + }); + + it('a throwing caller callback neither stops the restart nor escapes', () => { + const { r, reset } = setup({ + onVisualReset: () => { + throw new Error('host broke'); + }, + }); + expect(reset).not.toThrow(); + expect(r.sent.map((m) => decodeControl(m.raw)?.command)).toEqual(['checkout']); + }); + + it('does nothing once the client is stopped (control is released)', async () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + const client = launchMain('tok', { ipcMain: ipc.ipcMain, launch: f.launch } as never); + const r = fakeSender(); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r }, encodeHello()); + await client.stop(); + r.sent.length = 0; + (f.received?.options as unknown as { onVisualReset: () => void }).onVisualReset(); + expect(r.sent).toEqual([]); + }); + }); + it('replies to a renderer hello with the owner session id (the handshake)', () => { const ipc = fakeIpcMain(); const f = fakeLaunch(); diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index da523258..1c9c5ee3 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -12,6 +12,8 @@ import { BlackoutToken, CaptureStoreToken, createDiskPressure, + createSystemClock, + createVisualResetGate, getCarrierClient, resolveFailClosedBoolean, } from '@bugsee/core'; @@ -478,6 +480,25 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee } }; + // Main's store is the one with the byte cap, and the renderers' recorders stream into it. When it sheds every replay base + // the renderers are told to take a fresh full snapshot (control `checkout`); without that the stream has no base until a + // renderer's next periodic checkout. Paced by the same backoff the browser applies to its own store, and released by stop. + let checkoutRenderers: (() => void) | undefined; + const callerReset = nodeOptions.onVisualReset; + const resetClock = nodeOptions.clock ?? createSystemClock(); + const gateReset = createVisualResetGate({ + now: () => resetClock.monotonicNow(), + restart: () => checkoutRenderers?.(), + }); + nodeOptions.onVisualReset = (): void => { + try { + callerReset?.(); // the caller's own callback keeps working, and cannot cost the restart + } catch (error) { + reportGuarded(nodeOptions.onError, error); + } + gateReset(); + }; + const { client, internals } = (launch ?? launchCore)(appToken, nodeOptions); // `internals` is present only on the FIRST launch (the SDK is a per-process singleton); a repeat launch @@ -614,6 +635,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee ], }); control.start(); + checkoutRenderers = (): void => control.checkout(); // Arm the key-window stage and forward every change to the renderers whose answer moved (released in the wrapped stop). const offKeyWindow = keyWindow === undefined @@ -657,6 +679,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee tracker?.cancelGpuInfo(); // a late getGPUInfo resolve must not write after stop (an already-resolved GPU is kept) offBlackout(); offKeyWindow?.(); + checkoutRenderers = undefined; // a reset after stop has nobody to tell control.stop(); // broadcast stop to renderers + remove the hello listener receiver.stop(); videoController?.stop(); // stop pixel capture diff --git a/packages/electron/src/launch-renderer.test.ts b/packages/electron/src/launch-renderer.test.ts index 34a05a16..3f30fb91 100644 --- a/packages/electron/src/launch-renderer.test.ts +++ b/packages/electron/src/launch-renderer.test.ts @@ -185,6 +185,38 @@ describe('launchRenderer', () => { expect(owned.has('displays')).toBe(true); }); + it("main's checkout command restarts this renderer's replay recorder", async () => { + const f = fakeLaunch(); + const b = fakeBridge(); + await launchRenderer('tok', { launch: f.launch, bridge: b.bridge }); + const restart = vi.fn(); + ( + f.received?.options as unknown as { onReplayRestart: (fn: () => void) => void } + ).onReplayRestart(restart); + b.drive(encodeControl({ command: 'checkout' })); + expect(restart).toHaveBeenCalledTimes(1); + }); + + it('a checkout for a client that is no longer launched restarts nothing', async () => { + const f = fakeLaunch(); + const b = fakeBridge(); + await launchRenderer('tok', { launch: f.launch, bridge: b.bridge }); + const restart = vi.fn(); + ( + f.received?.options as unknown as { onReplayRestart: (fn: () => void) => void } + ).onReplayRestart(restart); + (f.client as unknown as { isLaunched: () => boolean }).isLaunched = () => false; + b.drive(encodeControl({ command: 'checkout' })); + expect(restart).not.toHaveBeenCalled(); + }); + + it('a checkout that arrives before the browser launch handed over its restart is ignored', async () => { + const f = fakeLaunch(); + const b = fakeBridge(); + await launchRenderer('tok', { launch: f.launch, bridge: b.bridge }); + expect(() => b.drive(encodeControl({ command: 'checkout' }))).not.toThrow(); + }); + it('a key-window message for a client that is no longer launched is ignored', async () => { const f = fakeLaunch(); const b = fakeBridge(); diff --git a/packages/electron/src/launch-renderer.ts b/packages/electron/src/launch-renderer.ts index 08c59350..48cac995 100644 --- a/packages/electron/src/launch-renderer.ts +++ b/packages/electron/src/launch-renderer.ts @@ -161,9 +161,16 @@ async function launchRendererOnce( host: ELECTRON_RENDERER_HOST_OWNED_TRACES, window: ELECTRON_PER_WINDOW_TRACES, }); + // The browser launch hands over a function that restarts the replay recorder (a fresh full snapshot). Main sends `checkout` + // when its store shed every replay base under the byte cap: this renderer's own store has no cap to hit, the cap is main's. + let restartReplay: (() => void) | undefined; const { client } = await (launch ?? launchCore)(appToken, { ...browserOptions, captureStore, + // After the caller's options: the control channel is the only thing that may restart the recorder from outside. + onReplayRestart: (restart: () => void): void => { + restartReplay = restart; + }, // Main writes the `displays` trace (the full list), the `capture` trace (blackout is session-wide, and pixel video is // main's) and its own V8 heap as `ram_js_heap_*`; a renderer must not write a second one into the same session. // After the caller's options: it is not the caller's to switch back on. @@ -260,6 +267,11 @@ async function launchRendererOnce( void client.flush(); } }, + onCheckout: (): void => { + if (live()) { + restartReplay?.(); + } + }, // main's declaration of the trace names it owns (the session reply carries it): applied before the hold opens onHostOwned: (names: readonly string[]): void => { if (live()) { diff --git a/packages/electron/src/main-control.test.ts b/packages/electron/src/main-control.test.ts index 9aac5433..ddb1b44e 100644 --- a/packages/electron/src/main-control.test.ts +++ b/packages/electron/src/main-control.test.ts @@ -83,6 +83,26 @@ describe('createElectronMainControl', () => { } }); + it('checkout() asks every live renderer for a fresh replay base, skipping destroyed and crashed ones', () => { + const ipc = fakeIpcMain(); + const control = createElectronMainControl({ ipcMain: ipc.ipcMain, sessionId: 's1' }); + control.start(); + const live = fakeSender(1); + const gone = { ...fakeSender(2), destroyed: false }; + const goneSender = Object.assign(gone.sender, { isDestroyed: () => gone.destroyed }); + const crashed = fakeSender(3); + const crashedSender = Object.assign(crashed.sender, { isCrashed: () => true }); + for (const sender of [live.sender, goneSender, crashedSender]) { + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender }, encodeHello()); + } + gone.destroyed = true; + control.checkout(); + expect(live.sent.slice(1).map((m) => decodeControl(m.raw)?.command)).toEqual(['checkout']); + expect(gone.sent.slice(1)).toEqual([]); + expect(crashed.sent.slice(1)).toEqual([]); + expect(control.rendererCount).toBe(2); // the destroyed one was pruned on the way past + }); + it('the hello reply carries the CURRENT blackout state, so a renderer born mid-blackout stays dark', () => { const ipc = fakeIpcMain(); let blackout = true; diff --git a/packages/electron/src/main-control.ts b/packages/electron/src/main-control.ts index 437cc750..8b91e108 100644 --- a/packages/electron/src/main-control.ts +++ b/packages/electron/src/main-control.ts @@ -89,6 +89,11 @@ export interface ElectronMainControl { blackoutStart(): void; /** Tell every renderer the owner's blackout ended. */ blackoutEnd(): void; + /** + * Ask every renderer to restart its replay recorder for a fresh full snapshot: main's store shed every replay base under + * its byte cap, so what the renderers stream next would otherwise have no base until their next periodic checkout. + */ + checkout(): void; /** * The key window may have changed: re-evaluate every registered renderer and send `window-key` to those whose answer * differs from what they were last told. A no-op without the `keyWindow` option. @@ -222,7 +227,7 @@ export function createElectronMainControl( }; const broadcast = ( - command: 'pause' | 'resume' | 'flush' | 'stop' | 'blackout-start' | 'blackout-end', + command: 'pause' | 'resume' | 'flush' | 'stop' | 'blackout-start' | 'blackout-end' | 'checkout', ): void => { const raw = encodeControl({ command }); for (const sender of [...renderers]) { @@ -355,6 +360,9 @@ export function createElectronMainControl( blackoutEnd(): void { broadcast('blackout-end'); }, + checkout(): void { + broadcast('checkout'); + }, keyWindowChanged: rediffKey, rendererGone(id: number): void { for (const sender of [...renderers]) { diff --git a/packages/electron/src/protocol.test.ts b/packages/electron/src/protocol.test.ts index 4113827b..71dc41f2 100644 --- a/packages/electron/src/protocol.test.ts +++ b/packages/electron/src/protocol.test.ts @@ -106,6 +106,10 @@ describe('encodeControl / decodeControl', () => { } }); + it('round-trips the checkout command (main asks every renderer for a fresh replay base)', () => { + expect(decodeControl(encodeControl({ command: 'checkout' }))).toEqual({ command: 'checkout' }); + }); + it('round-trips the session handshake reply (carries the assigned session id)', () => { const msg: ControlMessage = { command: 'session', sessionId: 'sess-42' }; expect(decodeControl(encodeControl(msg))).toEqual(msg); diff --git a/packages/electron/src/protocol.ts b/packages/electron/src/protocol.ts index aef0b4fd..1e831243 100644 --- a/packages/electron/src/protocol.ts +++ b/packages/electron/src/protocol.ts @@ -221,7 +221,9 @@ export type ControlCommand = | 'session' | 'blackout-start' | 'blackout-end' - | 'window-key'; + | 'window-key' + /** Main shed every replay base from its store (byte cap): each renderer restarts its recorder for a fresh full snapshot. */ + | 'checkout'; const CONTROL_COMMANDS: ReadonlySet = new Set([ 'pause', @@ -232,6 +234,7 @@ const CONTROL_COMMANDS: ReadonlySet = new Set([ 'blackout-start', 'blackout-end', 'window-key', + 'checkout', ]); export interface ControlMessage { diff --git a/packages/electron/src/renderer-control.test.ts b/packages/electron/src/renderer-control.test.ts index 7af9faef..712c9f2c 100644 --- a/packages/electron/src/renderer-control.test.ts +++ b/packages/electron/src/renderer-control.test.ts @@ -11,6 +11,7 @@ function harness(over: Partial[0 onBlackout: vi.fn(), onHostOwned: vi.fn(), onKeyWindow: vi.fn(), + onCheckout: vi.fn(), }; const handle = createRendererControlHandler({ ...calls, ...over }); return { ...calls, handle }; @@ -125,6 +126,19 @@ describe('createRendererControlHandler', () => { expect(h.onSession).not.toHaveBeenCalled(); }); + it('checkout → onCheckout(), and nothing else moves', () => { + const h = harness(); + h.handle(encodeControl({ command: 'checkout' })); + expect(h.onCheckout).toHaveBeenCalledTimes(1); + expect(h.setPaused).not.toHaveBeenCalled(); + expect(h.stop).not.toHaveBeenCalled(); + }); + + it('checkout without an onCheckout handler is ignored', () => { + const handle = createRendererControlHandler({ setPaused: vi.fn(), stop: vi.fn() }); + expect(() => handle(encodeControl({ command: 'checkout' }))).not.toThrow(); + }); + it('blackout-start / blackout-end → onBlackout(true / false)', () => { const h = harness(); h.handle(encodeControl({ command: 'blackout-start' })); diff --git a/packages/electron/src/renderer-control.ts b/packages/electron/src/renderer-control.ts index 9e39fe4a..dd03c25e 100644 --- a/packages/electron/src/renderer-control.ts +++ b/packages/electron/src/renderer-control.ts @@ -34,6 +34,11 @@ export interface RendererControlHandlerOptions { * `window-key` command (a missing flag there is "not key"). Optional. */ onKeyWindow?(isKey: boolean): void; + /** + * Main's store shed every replay base (its byte cap): restart the replay recorder so the stream gets a fresh full snapshot. + * Optional (a renderer without a recorder has nothing to restart). + */ + onCheckout?(): void; } /** Build the renderer's control dispatcher: `handle(raw)` applies one main→renderer control message. */ @@ -73,6 +78,9 @@ export function createRendererControlHandler( case 'window-key': options.onKeyWindow?.(message.keyWindow === true); break; + case 'checkout': + options.onCheckout?.(); + break; case 'blackout-start': options.onBlackout?.(true); break; diff --git a/packages/node/src/launch.data-size.test.ts b/packages/node/src/launch.data-size.test.ts index 784b3acc..a27a9f10 100644 --- a/packages/node/src/launch.data-size.test.ts +++ b/packages/node/src/launch.data-size.test.ts @@ -121,3 +121,27 @@ describe('launch — maxDataSize byte-bound wiring', () => { expect(fileSpy).not.toHaveBeenCalled(); }); }); + +describe('launch — onVisualReset reaches the capture store', () => { + it('passes the callback to the in-memory store', () => { + const onVisualReset = vi.fn(); + launchTracked({ capturedDataStore: 'memory', onVisualReset }); + expect(memSpy.mock.calls[0]?.[0]).toMatchObject({ onVisualReset }); + }); + + it('passes the callback to the file-backed store', () => { + const onVisualReset = vi.fn(); + const dir = mkdtempSync(join(tmpdir(), 'bugsee-visual-reset-')); + try { + launchTracked({ dataDir: dir, onVisualReset }); + expect(fileSpy.mock.calls[0]?.[0]).toMatchObject({ onVisualReset }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('passes nothing when none was given', () => { + launchTracked({ capturedDataStore: 'memory' }); + expect(memSpy.mock.calls[0]?.[0]).not.toHaveProperty('onVisualReset'); + }); +}); diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index c241f55c..c37f796b 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -445,6 +445,12 @@ export interface BugseeLaunchOptions { scheduler?: Scheduler; /** Capture store override; wins over dataDir/capturedDataStore. Default file-backed on disk; 'memory' opts out. */ captureStore?: CaptureStore; + /** + * Called when the byte cap forced every visual (replay) base out of the store, so a recorder that feeds it must take a + * fresh snapshot. For a host whose recorders live elsewhere (the Electron main, whose renderers stream into this store); + * it is not called for a `captureStore` you supply (that store is yours). Called at most once per drop, off the write path. + */ + onVisualReset?: () => void; /** System probe for the environment envelope. Default realSystemProbe. */ systemProbe?: SystemProbe; /** @@ -793,6 +799,7 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc const storeOptions = { maxRecordingTimeMs: maxRecordingTime * 1000, maxDataSizeBytes: maxDataSize * 1024 * 1024, + ...(options.onVisualReset !== undefined ? { onVisualReset: options.onVisualReset } : {}), ...(options.clock !== undefined ? { clock: options.clock } : {}), }; // This launch's capture generation (the launch epoch); shared by the live store, the marker hook, the From fefa2d26376917f3cacf5ddb9a3bd780a53cacd8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 10:53:19 +0500 Subject: [PATCH 15/33] fix(core,electron): recovery reports torn records to its own sink; main's checkout gate waits for a renderer (review round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/streaming-export.md | 2 +- packages/core/src/capture-recovery.test.ts | 76 +++++++++++++++++++ packages/core/src/capture-recovery.ts | 1 + .../core/src/native-crash-recovery.test.ts | 32 ++++++++ packages/core/src/recovered-bundle.test.ts | 18 +++++ packages/core/src/recovered-bundle.ts | 6 +- packages/electron/src/launch-main.test.ts | 20 +++++ packages/electron/src/launch-main.ts | 5 ++ 8 files changed, 157 insertions(+), 3 deletions(-) diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 9c3cfeaa..9c8dd654 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -215,7 +215,7 @@ consumer goes through one `asBody()` normalizer, so third-party `Transport` inje | **S2** | **DONE 2026-10-31** (four review rounds; `assembleBundleStream`, not yet wired into `client.ts`). Streaming assembler for the **built-in JSON types** + manifest/request/attachments, behind the same `assembleBundle` outcome (byte-equivalent after unzip, asserted against the buffered assembler on fixtures) | equivalence tests | | **S3** | **DONE 2026-10-31** (`timeRange()` on chunk-store snapshots, exposed on the exporter stream; assembler takes `time.start` from it and trims rolling non-encoder records before it; replay clamp is S4). `CaptureSnapshot.timeRange()` from the chunk store; plan; manifest `time` from the plan; non-replay trim | store + assembler tests, mutants on the bounds | | **S4** | **DONE 2026-10-31** (two review rounds; `createReplayStreamEncoder`, assembler `streamEncoders`; real rrweb Replayer probe on the BGSVIEWER-12 events passed; not yet wired into launch/client, that is S5). Replay `FileStreamEncoder` in `@bugsee/replay`: gzip stream, orphan trim, timestamp clamp, empty-member omission | encoder tests incl. real rrweb Replayer probe on the BGSVIEWER-12 data | -| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Recovery paths (closed after S6):** capture-recovery and native-crash-recovery now stream too (`recovered-bundle.ts`: `streamReified` over a per-bundle snapshot into `assembleBundleStream`; one snapshot per incident/dump instead of one shared drain, so nothing of the generation is held between bundles; a throwing pipeline gets the body disposed). Viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | +| **S5a** | **DONE 2026-11-01** (two review rounds): `BundleBody.ready`, `HttpTransport.acceptsBundleBody`, uploader/pipeline take and dispose a `BundleBody`, sinks (memory, Blob, node temp file + sweep), assembler write queue with backpressure/abort, node pipe + fetch Blob transports, `withTransportHeaders`. **S5b DONE 2026-11-01**: `BundleStore.putFrame?` (node: atomic temp+rename), `retainBody` shared by staging and upload, staging that outlives the upload frees its own blob, `flush` waits for streamed staging; stores without `putFrame` (IndexedDB mirror) get the materialized frame through `put`. Known: a streamed `enqueue` verdict waits up to `stagedWaitMs` (a slow big write reports `retained:false`, the safe direction); recovery still reads a staged frame whole (bounded by retention). **S5c DONE 2026-11-01** (three review rounds): `client.ts` assembles through `assembleBundleStream` over the exporter stream (+ report-time extras), the report dialog freezes (reads out) its capture with the window it covered, an export failure retries through memory and ships the report without rolling capture data, default memory sink keeps plain-bytes bundles, node builds into `/export/` (file sink, swept at launch), streamed durable staging, incremental `node:crypto` digest for file bodies (`sha256Stream`), browser registers the streaming replay encoder; chunk-store epoch moves back to early-stamped records so nothing in the window is trimmed. **Recovery paths (closed after S6):** capture-recovery and native-crash-recovery now stream too (`recovered-bundle.ts`: `streamReified` over a per-bundle snapshot into `assembleBundleStream`; one snapshot per incident/dump instead of one shared drain, so nothing of the generation is held between bundles; a throwing pipeline gets the body disposed; known cost: a generation with several markers is read once per marker, and the file/IndexedDB backends read a snapshot eagerly, so only the single-incident case saves memory and a stalled IndexedDB is waited on once per marker). Viewer unzip not exercised (worker-side Python `zipfile` verified against a streamed bundle). | | | **S5** | Sinks (Blob, node temp file) + transports + `BundleStore` streaming `put/open` + durable pipeline + recovery paths; worker/viewer compatibility check against the real worker test and viewer unzip | cross-package integration + e2e | | **S6** | **DONE 2026-11-01.** Default byte cap (browser: 20 MB memory / 30 MB durable; section 9), `PROGRESS.md`. The buffered `assembleBundle` is no longer used by any SDK path (recovery moved onto the streaming assembler after S6); it stays as the reference the streaming assembler is tested against | gates | diff --git a/packages/core/src/capture-recovery.test.ts b/packages/core/src/capture-recovery.test.ts index fef7266e..532fd20f 100644 --- a/packages/core/src/capture-recovery.test.ts +++ b/packages/core/src/capture-recovery.test.ts @@ -190,6 +190,82 @@ describe('recoverReports', () => { expect((pipe.bundles[0] as Bundle).request.email).toBe('who@x.com'); // userIdentifier → email }); + it('a torn record in the generation still yields the bundle and removes the marker; the options sink hears of it, not the context sink', async () => { + const storage = createInMemoryChunkStorage(); + seedGen(storage, 100, [ + logRecord(1, { m: 'good' }), + { type: 'log', timestamp: 2, serialized: 'NOT-JSON' } as StoredEntry, + ]); + const pipe = fakePipeline(); + const markers = fakeMarkers([marker('inc1', 100)]); + const onError = vi.fn(); + const contextOnError = vi.fn(); + await recoverReports({ + backend: readBackend(storage), + currentGeneration: 999, + markers, + context: () => ({ ...baseContext(), onError: contextOnError }), + uploadPipeline: pipe, + onError, + }); + expect(logsOf(pipe.bundles[0] as Bundle)).toEqual([{ m: 'good' }]); + expect(markers.list()).toEqual([]); + expect(onError).toHaveBeenCalledTimes(1); + expect(contextOnError).not.toHaveBeenCalled(); + }); + + it('a snapshot that cannot be taken keeps the marker and its generation for the next launch, and reports it', async () => { + const storage = createInMemoryChunkStorage(); + seedGen(storage, 100, [logRecord(1, { m: 'x' })]); + const backend = readBackend(storage); + vi.spyOn(backend, 'snapshot').mockImplementation(() => { + throw new Error('cannot snapshot'); + }); + const pipe = fakePipeline(); + const markers = fakeMarkers([marker('inc1', 100)]); + const onError = vi.fn(); + await recoverReports({ + backend, + currentGeneration: 999, + markers, + context: baseContext, + uploadPipeline: pipe, + onError, + }); + expect(pipe.enqueue).not.toHaveBeenCalled(); + expect(markers.list()).toHaveLength(1); + expect(await backend.listGenerations()).toContain(100); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'cannot snapshot' })); + }); + + it('releases every snapshot it took, including when the pipeline throws (marker kept)', async () => { + const storage = createInMemoryChunkStorage(); + seedGen(storage, 100, [logRecord(1, { m: 'x' })]); + const backend = readBackend(storage); + const releases: number[] = []; + const real = backend.snapshot.bind(backend); + vi.spyOn(backend, 'snapshot').mockImplementation((parts) => { + const snap = real(parts); + const release = snap.release.bind(snap); + snap.release = () => { + releases.push(1); + release(); + }; + return snap; + }); + const markers = fakeMarkers([marker('a', 100), marker('b', 100)]); + await recoverReports({ + backend, + currentGeneration: 999, + markers, + context: baseContext, + uploadPipeline: { enqueue: () => Promise.reject(new Error('pipeline down')) }, + onError: () => {}, + }); + expect(releases).toHaveLength(2); + expect(markers.list()).toHaveLength(2); + }); + it('streams a generation through one snapshot per marker (N bundles, N snapshots)', async () => { const storage = createInMemoryChunkStorage(); seedGen(storage, 100, [logRecord(1, { m: 'x' })]); diff --git a/packages/core/src/capture-recovery.ts b/packages/core/src/capture-recovery.ts index 3159173b..10c13475 100644 --- a/packages/core/src/capture-recovery.ts +++ b/packages/core/src/capture-recovery.ts @@ -193,6 +193,7 @@ export async function recoverReports(options: RecoverReportsOptions): Promise { expect(source.claims).toEqual(['main.dmp']); }); + it('a torn record in the crashed generation costs only itself: the bundle still ships, the claim is made, onError hears of it', async () => { + const grouped = new Map([ + [ + 'log', + [ + { + type: 'log', + timestamp: 1, + serialized: JSON.stringify({ timestamp: 1, data: { m: 'ok' } }), + }, + { type: 'log', timestamp: 2, serialized: 'NOT-JSON' }, + ] as StoredEntry[], + ], + ]); + const { backend } = fakeBackend(grouped); + const source = fakeSource([dump('main.dmp', 1)]); + const { pipeline, enqueued } = fakePipeline(); + const onError = vi.fn(); + const result = await recoverNativeCrashes({ + backend, + marker: marker({ generation: 7 }), + source, + context: baseContext, + uploadPipeline: pipeline, + onError, + }); + expect(result).toEqual({ harvested: 1, settled: 1, complete: true }); + expect(enqueued).toHaveLength(1); + expect(source.claims).toEqual(['main.dmp']); + expect(onError).toHaveBeenCalledTimes(1); + }); + it('streams the crashed generation through one snapshot per dump, each released', async () => { const { backend, snapshotCalls, releases } = fakeBackend(); const source = fakeSource([dump('a.dmp', 1), dump('b.dmp', 2)]); diff --git a/packages/core/src/recovered-bundle.test.ts b/packages/core/src/recovered-bundle.test.ts index f9358b5b..6a415be9 100644 --- a/packages/core/src/recovered-bundle.test.ts +++ b/packages/core/src/recovered-bundle.test.ts @@ -69,6 +69,24 @@ describe('assembleFromSnapshot', () => { expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toHaveLength(1); }); + it('reports a torn record to an explicit sink instead of the context onError', async () => { + const contextOnError = vi.fn(); + const sink = vi.fn(); + const snap = snapshotOf([ + stored('log', 1_000, { m: 1 }), + { type: 'log', timestamp: 1_100, serialized: 'NOT-JSON' } as StoredEntry, + ]); + await assembleFromSnapshot( + request(), + snap, + defaultEntryFactory, + context({ onError: contextOnError }), + sink, + ); + expect(sink).toHaveBeenCalledTimes(1); + expect(contextOnError).not.toHaveBeenCalled(); + }); + it('tolerates a torn record when the context has no onError', async () => { const snap = snapshotOf([ stored('log', 1_000, { m: 1 }), diff --git a/packages/core/src/recovered-bundle.ts b/packages/core/src/recovered-bundle.ts index 0112fc7d..860a3987 100644 --- a/packages/core/src/recovered-bundle.ts +++ b/packages/core/src/recovered-bundle.ts @@ -13,7 +13,8 @@ import type { Bundle, UploadPipeline, UploadResult } from './transport'; /** * Build a bundle from `snapshot`, streaming. The snapshot is released when the read ends, on success and failure alike; - * a torn record is skipped and reported to `context.onError`, never allowed to fail the generation's recovery. + * a torn record is skipped and reported to `onTornRecord` (default `context.onError`), never allowed to fail the generation's + * recovery. The recovery paths pass their own sink: it is where the drain they replaced reported them. * * The default (memory) sink hands out plain bytes, as every assembler did; any other sink's body is passed on for the * upload pipeline to own. @@ -23,10 +24,11 @@ export async function assembleFromSnapshot( snapshot: CaptureSnapshot, factory: CaptureEntryFactory, context: BundleAssemblyContext, + onTornRecord: (error: unknown) => void = context.onError ?? ((): void => {}), ): Promise { const assembled = await assembleBundleStream( request, - streamReified(snapshot, factory, context.onError ?? ((): void => {})), + streamReified(snapshot, factory, onTornRecord), context, ); return { diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index 14d5041b..acbe3315 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -182,6 +182,26 @@ describe('launchMain', () => { expect(r.sent.map((m) => decodeControl(m.raw)?.command)).toEqual(['checkout']); }); + it('a reset while no renderer is registered spends none of the budget, so the first renderer is not made to wait', () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + let now = 0; + launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + clock: { wallNow: () => now, monotonicNow: () => now }, + } as never); + const reset = (): void => + (f.received?.options as unknown as { onVisualReset: () => void }).onVisualReset(); + reset(); // nobody to tell + now = 1_000; + const r = fakeSender(); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r }, encodeHello()); + r.sent.length = 0; + reset(); // inside what would have been the quiet period + expect(r.sent.map((m) => decodeControl(m.raw)?.command)).toEqual(['checkout']); + }); + it('is paced: a reset inside the quiet period asks nothing, a later one asks again (backing off)', () => { const { r, reset, at } = setup(); reset(); diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 1c9c5ee3..aee23df7 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -484,10 +484,13 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee // the renderers are told to take a fresh full snapshot (control `checkout`); without that the stream has no base until a // renderer's next periodic checkout. Paced by the same backoff the browser applies to its own store, and released by stop. let checkoutRenderers: (() => void) | undefined; + let mainControl: { readonly rendererCount: number } | undefined; const callerReset = nodeOptions.onVisualReset; const resetClock = nodeOptions.clock ?? createSystemClock(); const gateReset = createVisualResetGate({ now: () => resetClock.monotonicNow(), + // Nobody to tell (no renderer has said hello yet, or none is left): the reset must not spend the backoff budget. + ready: () => (mainControl?.rendererCount ?? 0) > 0, restart: () => checkoutRenderers?.(), }); nodeOptions.onVisualReset = (): void => { @@ -635,6 +638,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee ], }); control.start(); + mainControl = control; checkoutRenderers = (): void => control.checkout(); // Arm the key-window stage and forward every change to the renderers whose answer moved (released in the wrapped stop). const offKeyWindow = @@ -680,6 +684,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee offBlackout(); offKeyWindow?.(); checkoutRenderers = undefined; // a reset after stop has nobody to tell + mainControl = undefined; control.stop(); // broadcast stop to renderers + remove the hello listener receiver.stop(); videoController?.stop(); // stop pixel capture From dc5de1210a91c38e210ccaea0e564d93ee650674 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 12:13:48 +0500 Subject: [PATCH 16/33] perf(browser-utils): the IndexedDB snapshot streams part by part, so the streaming export no longer holds the whole window (measured) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit snapshot().stream() read every part into one array before yielding its first record, so exporting from IndexedDB kept the decoded window in the heap. Measured in headless Chromium on a 20 MB window: buffered +77 MB, streaming with the old read +33 MB, part-by-part +21 MB (store side alone +1.7 MB). drainAll shares the same generator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/streaming-export.md | 13 ++- .../src/idb-chunk-backend.test.ts | 84 +++++++++++++++++++ .../browser-utils/src/idb-chunk-backend.ts | 25 +++--- 4 files changed, 109 insertions(+), 15 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 98bb9a7c..2efdeef9 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **Still open:** per-window visual lanes in the Electron main (see above); the IndexedDB read path of the streaming export is unmeasured. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Still open:** per-window visual lanes in the Electron main (see above); the ~20 MB assembler residual of the browser export is unexplained. diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 9c8dd654..9914a4d4 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -299,8 +299,17 @@ is the REPORT-TIME copy: the same 20 MB window needed 80 MB of heap to be export `sdk.options` shows the value in effect. Unchanged: Node/Electron 50 MB, edge/worker 10 MB (no replay there; no BGSVIEWER-12 exposure, no durable window). -Limits of the measurement: the durable rows are the node FILE-backed chunk store, not the browser's IndexedDB backend, whose -read path (`snapshot()` reading parts back from the database) was not measured; and the claim "the durable window is not in the +**IndexedDB read path (measured 2026-11-01, headless Chromium 1234, real IndexedDB, the same 20 MB window of 74 one-second +parts; live heap above the idle store, forced GC every 200 records, sampled inside the pipeline because the buffered build is +synchronous):** buffered export +77 MB; streaming export with the original read +33 MB; with the part-by-part read +21 MB. +The original `snapshot().stream()` read EVERY part into one array before yielding the first record, so the window sat in the +heap decoded; it now reads and decodes one part at a time (`readParts` in `idb-chunk-backend.ts`), and the store side alone costs ++1.7 MB (iterating the raw stream). The remaining ~20 MB is in the assembler, not the store (the in-memory store measures +31 MB +in the same harness); it has not been bisected, and it is not the +4 MB of the node file-store figure above, which came from a +different harness, so the two numbers are not comparable. A failure in a later part now surfaces after earlier parts were served +(the export's memory-build fallback handles it as before). Harness: `scratchpad/idbbench` (not committed). + +Limit of the measurement: the claim "the durable window is not in the heap" holds while IndexedDB accepts writes: records it refuses (quota, `AbortError`) are served from memory, still bounded by the cap, so under disk pressure the heap window can be the full 30 MB. diff --git a/packages/browser-utils/src/idb-chunk-backend.test.ts b/packages/browser-utils/src/idb-chunk-backend.test.ts index 5f1ac744..5ba6cb98 100644 --- a/packages/browser-utils/src/idb-chunk-backend.test.ts +++ b/packages/browser-utils/src/idb-chunk-backend.test.ts @@ -1658,3 +1658,87 @@ describe('createIdbChunkBackend — unpin(): a read left to finish in the backgr expect(await dataKeys(inner)).toBe(0); }); }); + +describe('createIdbChunkBackend — stream() reads one part at a time', () => { + const setup = async () => { + const inner = keyed(); + const reads: string[] = []; + const store: AsyncKeyedStore = { + ...inner, + readPrefix: (prefix) => { + reads.push(prefix); + return inner.readPrefix(prefix); + }, + }; + const b = createIdbChunkBackend(store, { generation: 5, cleanOtherGenerations: false }); + for (const n of [0, 1, 2]) { + b.openPart(ref(5, n), n); + b.appendEntry(ref(5, n), rec(`a${n}`, 'log', n)); + b.appendEntry(ref(5, n), rec(`b${n}`, 'log', n)); + b.closePart(ref(5, n), n + 1, 4); + } + await b.flush?.(); + const frozen = [0, 1, 2].map((n) => ({ ref: ref(5, n), count: 2 })); + return { b, reads, frozen }; + }; + + it('does not read a part until the consumer has taken the one before it', async () => { + const { b, reads, frozen } = await setup(); + const iterator = b.snapshot(frozen).stream(); + expect((await iterator.next()).value).toMatchObject({ serialized: 'a0' }); + expect(reads).toEqual([dataPrefixFor(5, 0)]); + await iterator.next(); // b0: still part 0 + expect(reads).toHaveLength(1); + expect((await iterator.next()).value).toMatchObject({ serialized: 'a1' }); + expect(reads).toEqual([dataPrefixFor(5, 0), dataPrefixFor(5, 1)]); + }); + + it('still serves every record in order, and drainAll agrees', async () => { + const { b, frozen } = await setup(); + expect(await collect(b.snapshot(frozen))).toEqual(['a0', 'b0', 'a1', 'b1', 'a2', 'b2']); + const drained = await b.snapshot(frozen).drainAll(); + expect([...drained.values()].flat().map((r) => r.serialized)).toEqual([ + 'a0', + 'b0', + 'a1', + 'b1', + 'a2', + 'b2', + ]); + }); + + it('a consumer that stops early never reads the rest', async () => { + const { b, reads, frozen } = await setup(); + const snap = b.snapshot(frozen); + const iterator = snap.stream(); + await iterator.next(); + await iterator.return?.(undefined); + snap.release(); + expect(reads).toHaveLength(1); + }); + + it('a release while a part is being read ends the stream without touching the next part', async () => { + const inner = keyed(); + const reads: string[] = []; + const store: AsyncKeyedStore = { + ...inner, + readPrefix: (prefix) => { + reads.push(prefix); + return prefix.startsWith('d/') ? new Promise(() => {}) : inner.readPrefix(prefix); + }, + }; + const b = createIdbChunkBackend(store, { generation: 5, cleanOtherGenerations: false }); + for (const n of [0, 1]) { + b.openPart(ref(5, n), n); + b.appendEntry(ref(5, n), rec(`a${n}`, 'log', n)); + b.closePart(ref(5, n), n + 1, 2); + } + await b.flush?.(); + const snap = b.snapshot([0, 1].map((n) => ({ ref: ref(5, n), count: 1 }))); + const pending = collect(snap); + await vi.waitFor(() => expect(reads).toHaveLength(1)); + snap.release(); + expect(await pending).toEqual([]); + expect(reads).toEqual([dataPrefixFor(5, 0)]); + }); +}); diff --git a/packages/browser-utils/src/idb-chunk-backend.ts b/packages/browser-utils/src/idb-chunk-backend.ts index d14fcce0..e7d0242c 100644 --- a/packages/browser-utils/src/idb-chunk-backend.ts +++ b/packages/browser-utils/src/idb-chunk-backend.ts @@ -530,23 +530,25 @@ export function createIdbChunkBackend( .map(([seq, record]) => ({ seq, record })) .sort((a, b) => a.seq - b.seq); - const readAll = async (): Promise => { + // One part at a time: only the part being served is ever decoded and held, so a consumer that streams the + // records (the export) pays for one part, not for the window. A failed read throws when its part is reached, + // after the earlier parts were already served. + const readParts = async function* (): AsyncGenerator { const waited = await until(waitForQueue(frozenQueue)); if (waited === CANCELLED) { - return []; + return; } if (waited === STALLED) { throw new Error( `bugsee: IndexedDB made no progress for ${stallMs} ms; the queued writes are served from memory`, ); } - const out: StoredEntry[] = []; for (const [index, { ref, count }] of frozen.entries()) { const records = await until( withDeadline(store.readPrefix(dataPrefix(ref.generation, ref.number)), readTimeoutMs), ); if (records === CANCELLED) { - return []; + return; } // By SEQUENCE, not by position: a refused write leaves a hole in the sequence, and the freeze is `seq < count`. const landed: Array<{ seq: number; record: StoredEntry }> = []; @@ -567,10 +569,9 @@ export function createIdbChunkBackend( const merged = kept.length === 0 ? landed : [...landed, ...kept].sort((a, b) => a.seq - b.seq); for (const { record } of merged) { - out.push(record); + yield record; } } - return out; }; // Drops this snapshot's PINS: an evicted part may then be deleted at once. Idempotent, and shared by `release()`. @@ -610,13 +611,13 @@ export function createIdbChunkBackend( }; return { - async *stream(): AsyncIterableIterator { - for (const record of await readAll()) { - yield record; - } - }, + stream: (): AsyncIterableIterator => readParts(), async drainAll(): Promise> { - return groupByType(await readAll()); + const all: StoredEntry[] = []; + for await (const record of readParts()) { + all.push(record); + } + return groupByType(all); }, release, // The read goes on (its result is discarded) but must not delay evictions: pins dropped, nothing else. From bfb28377827d8c24a8ea15808a54c45ce2a78203 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 13:10:24 +0500 Subject: [PATCH 17/33] feat(core,browser-utils): one visual lane and one replay member per source window (per-window replay L0-L2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records carry an optional out-of-band lane (a window). The chunk store keeps a visual lane per key: its own open part, base accounting, time eviction and byte-cap reset (onVisualReset(lane) names the lane that lost its bases). Every backend attaches the lane from the frozen part on read; the reifier copies it onto the entry; the streaming assembler writes one stream-encoded member per lane (replay.bin, replay.1.bin, ... with attrs.window). Unkeyed capture is unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/per-window-replay.md | 39 ++++++ .../src/idb-chunk-backend.test.ts | 25 ++++ .../browser-utils/src/idb-chunk-backend.ts | 12 +- .../core/src/bundle-stream-assembler.test.ts | 101 ++++++++++++++++ packages/core/src/bundle-stream-assembler.ts | 79 +++++++++---- packages/core/src/capture-data-entry.ts | 3 + packages/core/src/capture-drain.test.ts | 24 ++++ packages/core/src/capture-drain.ts | 10 +- packages/core/src/capture-exporter.test.ts | 15 +++ packages/core/src/capture-exporter.ts | 9 +- packages/core/src/chunk-backend.ts | 2 + packages/core/src/chunk-capture-store.test.ts | 111 ++++++++++++++++++ packages/core/src/chunk-capture-store.ts | 86 +++++++++----- packages/core/src/contracts.ts | 9 ++ packages/core/src/disk-pressure-backend.ts | 14 ++- packages/core/src/disk-pressure-store.test.ts | 14 +++ packages/core/src/file-chunk-backend.test.ts | 22 ++++ packages/core/src/file-chunk-backend.ts | 9 +- 18 files changed, 512 insertions(+), 72 deletions(-) create mode 100644 docs/design/per-window-replay.md diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md new file mode 100644 index 00000000..49571e9d --- /dev/null +++ b/docs/design/per-window-replay.md @@ -0,0 +1,39 @@ +# Per-window replay (and per-display pixel video) + +Status: owner decision 2026-11-01. Follow-up of `streaming-export.md` and `electron.md` (D8). + +## Owner rule + +* Runtimes that can read **actual pixels** compose the windows of one display into ONE frame, so a report carries one `video` + per display (the mobile SDKs' model; display ids are the ones the `displays` trace already uses, id 0 = the key window's display). +* Runtimes that **reconstruct** the screen (rrweb DOM replay) capture **per window**: one `replay` stream per window, because + windows are separate documents and cannot be composed into one frame. + +## Problem + +Main's capture store has one visual lane, and the exporter writes one `replay.bin`. With several windows their rrweb events +interleave in one stream (a player applies window A's increments to window B's snapshot), time eviction keeps only the newest +base across windows, and a byte-cap reset restarts every recorder. The replay of a multi-window app is wrong today. + +## Design (replay half; the pixel half is its own slice, see Slices) + +| Layer | Change | +|---|---| +| `StoredEntry` | optional out-of-band `lane?: string` (a source key: the window id as text). Routing needs no deserialize. Absent = the single unkeyed lane (every runtime but Electron main; behavior unchanged). | +| Chunk store | a visual lane per key: its own open part, its own base accounting. Time eviction keeps the newest anchored part at or before the window start PER KEY. Byte-cap shedding sheds the lane that holds the OLDEST visual bytes; `onVisualReset(lane)` names the lane whose bases are all gone. | +| `FrozenPart` / backends | `FrozenPart.lane` carries the key; every backend attaches it to the records it yields (memory keeps it on the record; file, IndexedDB and the disk-pressure wrapper attach it from the frozen part). Part meta is NOT extended: a recovered prior generation exports as one unkeyed stream (documented limit). | +| `CaptureDataEntry` | `lane?: string` copied from the stored record by the reifier. | +| Export | members are grouped by `(type, lane)`. The first lane (by first record) keeps the canonical name (`replay.bin`); later lanes get `replay..bin` with a manifest entry `{type: 'replay', attrs: {window: }}`. Each member has its own stream encoder session (orphan trim and clamp are per window). A viewer that reads only `replay.bin` still gets a coherent single-window replay. | +| Electron main | tags each renderer record with its window id as `lane`; `onVisualReset(lane)` restarts ONLY that window's recorder (`checkout` carries the window id; the gate paces per lane). | +| Pixel video | separate slice: main composes the windows of a display with `getBounds()` and z-order into one frame per tick; one `video` member per display (`video..webm`, attrs `{display: id}`). | + +## Slices + +* **L0** core: `lane` on records, per-lane visual state in the chunk store, per-lane reset callback (memory backend). +* **L1** file, IndexedDB and disk-pressure backends attach the lane on read. +* **L2** reifier + assembler: members per `(type, lane)`, naming, manifest attrs, per-member encoder sessions. +* **L3** Electron: window id as lane, per-window `checkout` and per-lane pacing. +* **L4** pixel video per display (compositor); viewer support for secondary members. + +Open (needs the viewer/mobile side): the exact secondary member names and manifest attrs are chosen here (`replay..bin`, +`attrs.window`), not taken from a mobile convention; they must be confirmed against the viewer before the viewer reads them. diff --git a/packages/browser-utils/src/idb-chunk-backend.test.ts b/packages/browser-utils/src/idb-chunk-backend.test.ts index 5ba6cb98..3996ff3a 100644 --- a/packages/browser-utils/src/idb-chunk-backend.test.ts +++ b/packages/browser-utils/src/idb-chunk-backend.test.ts @@ -1741,4 +1741,29 @@ describe('createIdbChunkBackend — stream() reads one part at a time', () => { expect(await pending).toEqual([]); expect(reads).toEqual([dataPrefixFor(5, 0)]); }); + + it("attaches the frozen part's lane to the records it reads back", async () => { + const b = createIdbChunkBackend(keyed(), { generation: 5, cleanOtherGenerations: false }); + b.openPart(ref(5, 0), 0); + b.appendEntry(ref(5, 0), rec('w', 'replay', 0)); + b.openPart(ref(5, 1), 0); + b.appendEntry(ref(5, 1), rec('u', 'replay', 0)); + await b.flush?.(); + const snap = b.snapshot([ + { ref: ref(5, 0), count: 1, lane: 'win-3' }, + { ref: ref(5, 1), count: 1 }, + ]); + const seen: Array<[string, string | undefined]> = []; + for await (const r of snap.stream()) { + seen.push([r.serialized, r.lane]); + } + expect(seen).toEqual([ + ['w', 'win-3'], + ['u', undefined], + ]); + const drained = ( + await b.snapshot([{ ref: ref(5, 0), count: 1, lane: 'win-3' }]).drainAll() + ).get('replay'); + expect(drained?.[0]?.lane).toBe('win-3'); + }); }); diff --git a/packages/browser-utils/src/idb-chunk-backend.ts b/packages/browser-utils/src/idb-chunk-backend.ts index e7d0242c..3ec97dfb 100644 --- a/packages/browser-utils/src/idb-chunk-backend.ts +++ b/packages/browser-utils/src/idb-chunk-backend.ts @@ -543,7 +543,7 @@ export function createIdbChunkBackend( `bugsee: IndexedDB made no progress for ${stallMs} ms; the queued writes are served from memory`, ); } - for (const [index, { ref, count }] of frozen.entries()) { + for (const [index, { ref, count, lane }] of frozen.entries()) { const records = await until( withDeadline(store.readPrefix(dataPrefix(ref.generation, ref.number)), readTimeoutMs), ); @@ -561,7 +561,15 @@ export function createIdbChunkBackend( if (d === undefined) { continue; // one torn envelope loses ONE record, never the generation } - landed.push({ seq, record: { type: d.ty, timestamp: d.t, serialized: d.s } }); + landed.push({ + seq, + record: { + type: d.ty, + timestamp: d.t, + serialized: d.s, + ...(lane !== undefined ? { lane } : {}), + }, + }); } // An entry can be BOTH landed (the read saw it) and in the frozen copy: by sequence it is one record. const landedSeqs = new Set(landed.map(({ seq }) => seq)); diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 59b4a06f..04bad3af 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -1088,3 +1088,104 @@ describe('assembleBundleStream — sink', () => { expect(calls).toBe(1); }); }); + +describe('assembleBundleStream — one stream-encoded member per visual source lane', () => { + const NOW3 = 1_700_000_000_000; + const laned = (lane: string | undefined, at: number, data: string): CaptureDataEntry => { + const e = new CaptureDataEntryBase('replay', at, data); + if (lane !== undefined) e.lane = lane; + return e; + }; + const recorder = (silentLane?: string) => { + const sessions: string[][] = []; + const encoder = { + begin: () => { + const mine: string[] = []; + sessions.push(mine); + let silent = false; + return { + write: (e: CaptureDataEntry) => { + mine.push(String(e.data)); + silent = e.lane === silentLane && silentLane !== undefined; + return silent ? [] : [strToU8(`<${String(e.data)}>`)]; + }, + end: () => (silent ? [] : [strToU8('|end')]), + }; + }, + }; + return { encoder, sessions }; + }; + const run = async (entries: CaptureDataEntry[], encoder: unknown) => { + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + from(entries), + context({ streamEncoders: { replay: encoder as never } }), + ); + const files = members(await bundle.body.bytes()); + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + files: Array<{ filename: string; type: string; attrs?: Record }>; + }; + return { files, manifest }; + }; + + it('writes each window its own member, the first under the canonical name, with its own encoder session', async () => { + const { encoder, sessions } = recorder(); + const { files, manifest } = await run( + [ + laned('3', NOW3 - 300, 'a1'), + laned('9', NOW3 - 250, 'b1'), + laned('3', NOW3 - 200, 'a2'), + laned('9', NOW3 - 100, 'b2'), + ], + encoder, + ); + expect(sessions).toEqual([ + ['a1', 'a2'], + ['b1', 'b2'], + ]); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(strFromU8(files['replay.1.bin'] as Uint8Array)).toBe('|end'); + const replays = manifest.files.filter((f) => f.type === 'replay'); + expect(replays).toEqual([ + expect.objectContaining({ filename: 'replay.bin', attrs: { window: '3' } }), + expect.objectContaining({ filename: 'replay.1.bin', attrs: { window: '9' } }), + ]); + }); + + it('a window that produced nothing leaves no member and does not take the canonical name', async () => { + const { encoder } = recorder('3'); + const { files, manifest } = await run( + [laned('3', NOW3 - 300, 'quiet'), laned('9', NOW3 - 100, 'b1')], + encoder, + ); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('|end'); + expect(files['replay.1.bin']).toBeUndefined(); + expect(manifest.files.filter((f) => f.type === 'replay').map((f) => f.filename)).toEqual([ + 'replay.bin', + ]); + }); + + it('keeps unkeyed records in the one canonical member with no window attribute', async () => { + const { encoder, sessions } = recorder(); + const { manifest } = await run( + [laned(undefined, NOW3 - 200, 'u1'), laned(undefined, NOW3 - 100, 'u2')], + encoder, + ); + expect(sessions).toEqual([['u1', 'u2']]); + expect(manifest.files.filter((f) => f.type === 'replay')).toEqual([ + { filename: 'replay.bin', type: 'replay' }, + ]); + }); + + it('ignores the lane of a type that has no stream encoder', async () => { + const log = new CaptureDataEntryBase('log', NOW3 - 100, 'l'); + log.lane = '3'; + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + from([log, Object.assign(new CaptureDataEntryBase('log', NOW3 - 50, 'm'), { lane: '9' })]), + context(), + ); + const files = members(await bundle.body.bytes()); + expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['l', 'm']); + }); +}); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index c706d093..413ed3f5 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -36,11 +36,26 @@ import type { Bundle } from './transport'; type TimeRange = { readonly start: number; readonly end: number }; interface EncodedMember { + readonly type: FileType; + /** The visual source this member holds (`CaptureDataEntry.lane`); absent for the unkeyed source. */ + readonly lane: string | undefined; readonly session: StreamEncoderSession; - /** Created with the first chunk, so a type that produced nothing leaves no empty member behind. */ + /** Created with the first chunk, so a source that produced nothing leaves no empty member behind. */ file: ZipDeflate | undefined; + /** The member's file name, fixed when the file is created. */ + name: string | undefined; } +/** `replay.bin` for the first member of a type, `replay.1.bin`, `replay.2.bin`, ... for the next ones. */ +const nthFileName = (type: FileType, index: number): string => { + const base = fileNameForType(type); + if (index === 0) { + return base; + } + const dot = base.lastIndexOf('.'); + return dot === -1 ? `${base}.${index}` : `${base.slice(0, dot)}.${index}${base.slice(dot)}`; +}; + interface StreamMember { readonly file: ZipDeflate; readonly shape: ReturnType; @@ -197,14 +212,18 @@ async function buildInto( // With a plan the report starts where the plan says (the window), whatever the records are; without one, at the earliest // record. The plan also bounds the ROLLING records: older ones (the store keeps up to a second of slack) are dropped. let start = plan?.start ?? now; - const order: FileType[] = []; - const streamed = new Map(); - const collected = new Map(); // types with an injected encoder - const encoded = new Map(); // types with a stream encoder - const writeChunks = (type: FileType, member: EncodedMember, chunks: Uint8Array[]): void => { + const order: string[] = []; // member keys: a type, or `type\0lane` for one visual source of a stream-encoded type + const streamed = new Map(); + const collected = new Map(); // types with an injected encoder + const encoded = new Map(); // types with a stream encoder: one member per visual source lane + const filesOfType = new Map(); // members actually created per type (names the next one) + const writeChunks = (member: EncodedMember, chunks: Uint8Array[]): void => { for (const chunk of chunks) { if (member.file === undefined) { - member.file = new ZipDeflate(claim(fileNameForType(type))); + const index = filesOfType.get(member.type) ?? 0; + filesOfType.set(member.type, index + 1); + member.name = claim(nthFileName(member.type, index)); + member.file = new ZipDeflate(member.name); zip.add(member.file); } member.file.push(chunk, false); @@ -224,23 +243,29 @@ async function buildInto( ) { return; // before the window; types with an encoder (the replay) are clamped by it instead } - if (!streamed.has(type) && !collected.has(type) && !encoded.has(type)) { - order.push(type); - const streamEncoder = context.streamEncoders?.[type]; + const streamEncoder = context.streamEncoders?.[type]; + // Only a stream-encoded (reconstructed) type splits by source: every other type is one member whatever its lane. + const key = + streamEncoder !== undefined && entry.lane !== undefined ? `${type}\0${entry.lane}` : type; + if (!streamed.has(key) && !collected.has(key) && !encoded.has(key)) { + order.push(key); if (streamEncoder !== undefined) { - encoded.set(type, { + encoded.set(key, { + type, + lane: entry.lane, session: streamEncoder.begin({ start: plan?.start, end: now }), file: undefined, + name: undefined, }); } else if (context.fileEncoders?.[type] !== undefined) { - collected.set(type, []); + collected.set(key, []); } else { const file = new ZipDeflate(claim(fileNameForType(type))); zip.add(file); - streamed.set(type, { file, shape: fileShape(type), count: 0 }); + streamed.set(key, { file, shape: fileShape(type), count: 0 }); } } - const session = encoded.get(type); + const session = encoded.get(key); if (session !== undefined) { let chunks: Uint8Array[]; try { @@ -250,15 +275,15 @@ async function buildInto( return; } // Outside the try: a name clash (or any zip failure) is not a bad record and must fail the bundle, as it does everywhere. - writeChunks(type, session, chunks); + writeChunks(session, chunks); return; } - const payloads = collected.get(type); + const payloads = collected.get(key); if (payloads !== undefined) { payloads.push(entry.data); return; } - const member = streamed.get(type) as StreamMember; + const member = streamed.get(key) as StreamMember; if (member.shape.single) { // `profile`: the first entry alone, as a bare object. if (member.count === 0) { @@ -287,18 +312,24 @@ async function buildInto( const files: ManifestFileEntry[] = []; const dataNames: string[] = []; - for (const type of order) { - const stream = encoded.get(type); + for (const key of order) { + const stream = encoded.get(key); + const type = (stream?.type ?? key) as FileType; if (stream !== undefined) { - writeChunks(type, stream, stream.session.end()); + writeChunks(stream, stream.session.end()); if (stream.file === undefined) { continue; // nothing was produced: no member, no manifest entry } stream.file.push(new Uint8Array(0), true); } - files.push(manifestEntryForType(type)); - dataNames.push(fileNameForType(type)); - const member = streamed.get(type); + const name = stream?.name ?? fileNameForType(type); + files.push({ + ...manifestEntryForType(type), + filename: name, + ...(stream?.lane !== undefined ? { attrs: { window: stream.lane } } : {}), + }); + dataNames.push(name); + const member = streamed.get(key); if (stream !== undefined) { // finalized above } else if (member !== undefined) { @@ -307,7 +338,7 @@ async function buildInto( const encode = (context.fileEncoders as NonNullable)[type] as ( payloads: unknown[], ) => Uint8Array; - addWhole(fileNameForType(type), encode(collected.get(type) as unknown[])); + addWhole(fileNameForType(type), encode(collected.get(key) as unknown[])); } } diff --git a/packages/core/src/capture-data-entry.ts b/packages/core/src/capture-data-entry.ts index bd40f3e6..36331c49 100644 --- a/packages/core/src/capture-data-entry.ts +++ b/packages/core/src/capture-data-entry.ts @@ -18,6 +18,9 @@ export class CaptureDataEntryBase implements CaptureDataEntry { public readonly anchor: boolean = false, ) {} + /** The visual source the record was read from (`StoredEntry.lane`); set by the reader, never serialized. */ + lane?: string; + serialize(): string { return JSON.stringify({ timestamp: this.timestamp, data: this.data }); } diff --git a/packages/core/src/capture-drain.test.ts b/packages/core/src/capture-drain.test.ts index 50f0a54a..f5b5f532 100644 --- a/packages/core/src/capture-drain.test.ts +++ b/packages/core/src/capture-drain.test.ts @@ -156,3 +156,27 @@ describe('streamReified', () => { expect(snap.releases).toBe(1); }); }); + +describe('reified entries carry their visual source lane', () => { + const withLane = (lane?: string): StoredEntry => ({ + ...stored('replay', { e: 1 }), + ...(lane !== undefined ? { lane } : {}), + }); + + it('streamReified sets the lane of a laned record and leaves an unlaned one without', async () => { + const snap = streamingSnapshot([withLane('win-3'), withLane()]); + const lanes: Array = []; + for await (const entry of streamReified(snap, defaultEntryFactory, () => {})) { + lanes.push(entry.lane); + } + expect(lanes).toEqual(['win-3', undefined]); + }); + + it('drainReified does the same', async () => { + const grouped = new Map([['replay', [withLane('win-3'), withLane()]]]); + const entries = (await drainReified(fakeSnapshot(grouped), defaultEntryFactory, () => {})).get( + 'replay', + ); + expect(entries?.map((e) => e.lane)).toEqual(['win-3', undefined]); + }); +}); diff --git a/packages/core/src/capture-drain.ts b/packages/core/src/capture-drain.ts index c5a24bc4..c8c7ca7c 100644 --- a/packages/core/src/capture-drain.ts +++ b/packages/core/src/capture-drain.ts @@ -7,9 +7,13 @@ import type { StoredEntry, } from './contracts'; -function reify(record: StoredEntry, factory: CaptureEntryFactory): CaptureDataEntry { +/** Rebuild a stored record as an entry, carrying the visual source lane (not part of the serialized payload). */ +export function reify(record: StoredEntry, factory: CaptureEntryFactory): CaptureDataEntry { const entry = factory(record.type); entry.deserialize(record.serialized); + if (record.lane !== undefined) { + entry.lane = record.lane; + } return entry; } @@ -92,9 +96,7 @@ export async function drainReified( const entries: CaptureDataEntry[] = []; for (const record of records) { try { - const entry = factory(type); - entry.deserialize(record.serialized); - entries.push(entry); + entries.push(reify(record, factory)); } catch (error) { // A torn trailing frame — the exact artifact a crash/SIGKILL leaves mid-write — yields one // un-deserializable record (the snapshot parser only guards the timestamp, not the payload). diff --git a/packages/core/src/capture-exporter.test.ts b/packages/core/src/capture-exporter.test.ts index 5ac253bf..d711f794 100644 --- a/packages/core/src/capture-exporter.test.ts +++ b/packages/core/src/capture-exporter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; import type { CaptureEntryFactory, CaptureSnapshot, CaptureStore, StoredEntry } from './contracts'; +import { createMemoryCaptureStore } from './memory-capture-store'; const rec = (type: FileType, timestamp: number, data: unknown): StoredEntry => ({ type, @@ -269,3 +270,17 @@ describe('createCaptureExporter — owner-scoped export', () => { expect(calls[0]?.owner).toBeUndefined(); }); }); + +describe('createCaptureExporter — lane', () => { + it('drain() entries keep the lane of the record they came from', async () => { + const store = createMemoryCaptureStore(); + store.add({ + type: 'replay', + timestamp: 1, + serialized: '{"timestamp":1,"data":1}', + lane: 'win-3', + }); + const replay = (await createCaptureExporter(store).drain()).get('replay'); + expect(replay?.[0]?.lane).toBe('win-3'); + }); +}); diff --git a/packages/core/src/capture-exporter.ts b/packages/core/src/capture-exporter.ts index 3b4321fc..150768d9 100644 --- a/packages/core/src/capture-exporter.ts +++ b/packages/core/src/capture-exporter.ts @@ -1,13 +1,12 @@ import type { FileType } from '@bugsee/protocol'; import { defaultEntryFactory } from './capture-data-entry'; -import { streamReified } from './capture-drain'; +import { reify, streamReified } from './capture-drain'; import type { CaptureDataEntry, CaptureEntryFactory, CaptureEntryStream, CaptureExporter, CaptureStore, - StoredEntry, } from './contracts'; // CaptureExporter (Android CaptureExporter parity): the read side of the capture pipeline. It takes a @@ -17,12 +16,6 @@ import type { // (memory-light); drain() reads all-at-once grouped by file type. The aggregator writes; the exporter // reads — the two directions are separate. -function reify(record: StoredEntry, factory: CaptureEntryFactory): CaptureDataEntry { - const entry = factory(record.type); - entry.deserialize(record.serialized); - return entry; -} - export function createCaptureExporter( store: CaptureStore, factory: CaptureEntryFactory = defaultEntryFactory, diff --git a/packages/core/src/chunk-backend.ts b/packages/core/src/chunk-backend.ts index 1689c9a8..a82b3624 100644 --- a/packages/core/src/chunk-backend.ts +++ b/packages/core/src/chunk-backend.ts @@ -30,6 +30,8 @@ export interface FrozenPart { readonly ref: PartRef; /** Records to include, oldest-first — the part's entry count at snapshot time (open-part boundary). */ readonly count: number; + /** The visual source every record of this part belongs to (see `StoredEntry.lane`); absent = unkeyed or not visual. */ + readonly lane?: string; } export interface ChunkBackend { diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 98f8bcc8..8129c26b 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -1,5 +1,6 @@ import type { FileType } from '@bugsee/protocol'; import { describe, expect, it, vi } from 'vitest'; +import type { FrozenPart } from './chunk-backend'; import { createChunkCaptureStore } from './chunk-capture-store'; import type { Clock } from './clock'; import type { StoredEntry } from './contracts'; @@ -477,3 +478,113 @@ describe('createChunkCaptureStore — snapshot time range', () => { expect(inner.released).toBe(true); }); }); + +describe('createChunkCaptureStore — one visual lane per source key (per-window replay)', () => { + // A replay record of `bytes` serialized bytes, labelled, in the lane `lane` (a window), at second `s`. + const rec = ( + lane: string | undefined, + label: string, + s: number, + anchor = false, + bytes = 10, + ): StoredEntry => ({ + type: 'replay', + timestamp: s * 1000, + serialized: label.padEnd(bytes, '.'), + ...(lane !== undefined ? { lane } : {}), + ...(anchor ? { anchor: true } : {}), + }); + const held = async (store: ReturnType) => { + const out: Record = {}; + for (const e of (await store.snapshot().drainAll()).get('replay') ?? []) { + const name = e.lane ?? ''; + out[name] = [...(out[name] ?? []), e.serialized.replace(/\.+$/, '')]; + } + return out; + }; + + it('time eviction keeps the newest base at or before the window start PER lane', async () => { + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 2000, + }); + // Window a opens a base at 0 and again at 5; window b opens one at 1 only. At t=10 the window starts at 8. + const plan: Array<[string, string, number, boolean]> = [ + ['a', 'a0', 0, true], + ['b', 'b1', 1, true], + ['b', 'b2', 2, false], + ['a', 'a5', 5, true], + ['a', 'a6', 6, false], + ]; + for (let s = 0; s < 10; s++) { + for (const [lane, label, at, anchor] of plan) { + if (at === s) { + store.add(rec(lane, label, s, anchor)); + } + } + store.tick((s + 1) * 1000); + } + const kept = await held(store); + expect(kept.a).toEqual(['a5', 'a6']); // a's older base is superseded + expect(kept.b).toEqual(['b1', 'b2']); // b's only base survives even though it is older than the window + }); + + it('the byte cap resets the lane whose bases are all gone, by name', async () => { + const reset = vi.fn(); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 1_000_000, + maxDataSizeBytes: 50, + onVisualReset: reset, + }); + store.add(rec('a', 'A', 0, true, 30)); // a: one base segment + store.tick(1000); + store.add(rec('b', 'B', 1, true, 15)); // b: one base segment, newer + store.tick(2000); + store.add(rec('b', 'b', 2, false, 15)); // 60 bytes > 50: the OLDEST visual bytes (a's lone base) go + await Promise.resolve(); + expect(reset).toHaveBeenCalledTimes(1); + expect(reset).toHaveBeenCalledWith('a'); + expect(await held(store)).toEqual({ b: ['B', 'b'] }); + }); + + it('an unkeyed record resets without an argument (the single-lane contract)', async () => { + const reset = vi.fn(); + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 1_000_000, + maxDataSizeBytes: 20, + onVisualReset: reset, + }); + store.add(rec(undefined, 'A', 0, true, 30)); + store.tick(1000); + store.add(rec(undefined, 'x', 1, false, 5)); + await Promise.resolve(); + expect(reset).toHaveBeenCalledWith(); + }); + + it('the snapshot hands every record back with its lane', async () => { + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 1_000_000, + }); + store.add(rec('a', 'a0', 0, true)); + store.add(rec('b', 'b0', 0, true)); + store.add(rec(undefined, 'u0', 0, true)); + expect(await held(store)).toEqual({ a: ['a0'], b: ['b0'], '': ['u0'] }); + }); + + it('freezes each visual part with its lane, and the data part without one', () => { + const inner = createMemoryChunkBackend(); + const seen: Array = []; + const store = createChunkCaptureStore( + { ...inner, snapshot: (parts) => (seen.push(parts), inner.snapshot(parts)) }, + { clock: clockAt(0), maxRecordingTimeMs: 1_000_000 }, + ); + store.add(rec('a', 'a0', 0, true)); + store.add(rec(undefined, 'u0', 0, true)); + store.add({ type: 'log', timestamp: 0, serialized: 'l' }); + store.snapshot().release(); + expect((seen[0] ?? []).map((part) => part.lane)).toEqual([undefined, 'a', undefined]); + }); +}); diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index 68bcf229..e03044f6 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -35,10 +35,11 @@ export interface ChunkCaptureStoreOptions { diskReadTimeoutMs?: number; /** * Called (once, deferred to a microtask so the recorder's synchronous restart cannot re-enter the store) when - * the byte cap forced the whole visual lane out and no new base is open: the host should restart its recorder so - * the next records start a fresh base (a replay's Meta + FullSnapshot). + * the byte cap forced a whole visual lane out and no new base is open: the host should restart its recorder so + * the next records start a fresh base (a replay's Meta + FullSnapshot). Called with the lane key (a window) of the + * lane that lost its bases, and with no argument for the unkeyed lane. */ - onVisualReset?: () => void; + onVisualReset?: (lane?: string) => void; } // The in-memory index entry per live part (NO record data — that lives in the backend). @@ -52,6 +53,8 @@ interface IndexedPart { anchor: boolean; /** `visual`: replay/video, shed first under the byte cap and kept anchored to a base. `data`: everything else. */ readonly lane: Lane; + /** The visual source key (`StoredEntry.lane`) of a visual part; absent for the data lane and the unkeyed source. */ + readonly key?: string; } type Lane = 'data' | 'visual'; @@ -101,16 +104,17 @@ export function createChunkCaptureStore( const generation = backend.generation; // Every live part, oldest-first by part number; both lanes share one number space and one backend, so - // snapshot and crash recovery see plain parts. The open part of each lane is `dataPart` / `visualPart` (the visual - // part is opened lazily by the first visual record of a tick, so a store that never sees one is unchanged). + // snapshot and crash recovery see plain parts. The open part of the data lane is `dataPart`; each visual source key has its own + // open part in `openVisual`, opened lazily by its first record of a tick (a store that never sees one is unchanged). let parts: IndexedPart[] = []; // Running byte total across all live parts; kept in sync with every add/evict so the byte cap and the // time window never double-count. let totalBytes = 0; let nextNumber = 0; - let visualPart: IndexedPart | undefined; + // The OPEN visual part of each source key ('' = the unkeyed source), opened lazily by its first record of a tick. + const openVisual = new Map(); - const openNewPart = (start: number, lane: Lane): IndexedPart => { + const openNewPart = (start: number, lane: Lane, key?: string): IndexedPart => { const ref: PartRef = { generation, number: nextNumber }; nextNumber += 1; backend.openPart(ref, start); @@ -122,6 +126,7 @@ export function createChunkCaptureStore( count: 0, anchor: false, lane, + ...(key !== undefined ? { key } : {}), }; parts.push(part); return part; @@ -134,12 +139,17 @@ export function createChunkCaptureStore( // can be in the window, so the export range never starts before it, and never AFTER a record it would then trim. let epoch = firstStart; - const partFor = (lane: Lane): IndexedPart => { - if (lane === 'data') { + const partFor = (record: StoredEntry): IndexedPart => { + if (!VISUAL_TYPES.has(record.type)) { return dataPart; } - visualPart ??= openNewPart(dataPart.start, 'visual'); - return visualPart; + const name = record.lane ?? ''; + let part = openVisual.get(name); + if (part === undefined) { + part = openNewPart(dataPart.start, 'visual', record.lane); + openVisual.set(name, part); + } + return part; }; const evictPart = (part: IndexedPart): void => { @@ -148,18 +158,24 @@ export function createChunkCaptureStore( backend.removePart(part.ref); }; - const closedParts = (lane: Lane): IndexedPart[] => - parts.filter((part) => part.lane === lane && part.end !== undefined); + const closedParts = (lane: Lane, key?: string): IndexedPart[] => + parts.filter( + (part) => + part.lane === lane && part.end !== undefined && (lane === 'data' || part.key === key), + ); // Make room for a byte cap by dropping VISUAL records only, always leaving what remains valid: records before // the first base are useless and go first; then the oldest base segment (everything before the next base); a lone // segment cannot be shrunk and stays valid, so it goes whole and the host is asked for a fresh base. // @returns whether anything was dropped. const shedVisual = (): boolean => { - const closed = closedParts('visual'); - if (closed.length === 0) { + // The lane that holds the OLDEST visual bytes pays first; each lane keeps its own bases valid. + const oldest = parts.find((part) => part.lane === 'visual' && part.end !== undefined); + if (oldest === undefined) { return false; } + const key = oldest.key; + const closed = closedParts('visual', key); const firstBase = closed.findIndex((part) => part.anchor); let upTo: number; if (firstBase !== 0) { @@ -171,13 +187,17 @@ export function createChunkCaptureStore( for (const part of closed.slice(0, upTo)) { evictPart(part); } - if (upTo === closed.length && visualPart?.anchor !== true) { + if (upTo === closed.length && openVisual.get(key ?? '')?.anchor !== true) { // Nothing valid is left to show and the open part has no base: a fresh one is needed. const reset = options?.onVisualReset; if (reset !== undefined) { void Promise.resolve().then(() => { try { - reset(); + if (key === undefined) { + reset(); + } else { + reset(key); + } } catch { /* a host callback must never break capture */ } @@ -206,7 +226,7 @@ export function createChunkCaptureStore( // the loss would buy nothing, so keep the in-window logs and let the cap be a soft bound. (Without a visual part // the old rule stands: the newest, oversized record is kept and the history before it goes.) if ( - visualPart !== undefined && + openVisual.size > 0 && totalBytes - closedData.reduce((sum, part) => sum + part.byteSize, 0) > maxDataSizeBytes ) { break; @@ -219,10 +239,12 @@ export function createChunkCaptureStore( // before the window start (the BASE the window's first records depend on) and everything after it, even though it is // older than the window; export trims what falls outside. No such part (no anchor recorded, or the only one is // newer) evicts as plain time. - const evictByTime = (lane: Lane, nowMs: number): void => { + const evictByTime = (lane: Lane, nowMs: number, key?: string): void => { const cutting = nowMs - maxRecordingTimeMs - PART_DURATION_MS; const windowStart = nowMs - maxRecordingTimeMs; - const laneParts = parts.filter((part) => part.lane === lane); + const laneParts = parts.filter( + (part) => part.lane === lane && (lane === 'data' || part.key === key), + ); let evictable = laneParts.length; if (lane === 'visual') { for (let i = laneParts.length - 1; i >= 0; i--) { @@ -246,7 +268,7 @@ export function createChunkCaptureStore( return { add(record: StoredEntry): void { - const part = partFor(VISUAL_TYPES.has(record.type) ? 'visual' : 'data'); + const part = partFor(record); const size = backend.appendEntry(part.ref, record); part.byteSize += size; part.count += 1; @@ -263,21 +285,29 @@ export function createChunkCaptureStore( tick(nowMs: number): void { dataPart.end = nowMs; backend.closePart(dataPart.ref, nowMs, dataPart.byteSize); - if (visualPart !== undefined) { - visualPart.end = nowMs; - backend.closePart(visualPart.ref, nowMs, visualPart.byteSize); - visualPart = undefined; + for (const open of openVisual.values()) { + open.end = nowMs; + backend.closePart(open.ref, nowMs, open.byteSize); } + openVisual.clear(); dataPart = openNewPart(nowMs, 'data'); // Evict closed parts whose end is outside the window (keep one extra part, Android parity). evictByTime('data', nowMs); - evictByTime('visual', nowMs); + // Each visual source keeps the base ITS first in-window records depend on. + const keys = new Set(parts.filter((part) => part.lane === 'visual').map((part) => part.key)); + for (const key of keys) { + evictByTime('visual', nowMs, key); + } }, snapshot(): CaptureSnapshot { // Freeze each in-window part with its CURRENT entry count — the boundary that keeps post-snapshot // captures (to the still-open part) out of the frozen view. The backend isolates the read. - const frozen: FrozenPart[] = parts.map((part) => ({ ref: part.ref, count: part.count })); + const frozen: FrozenPart[] = parts.map((part) => ({ + ref: part.ref, + count: part.count, + ...(part.key !== undefined ? { lane: part.key } : {}), + })); const end = clock.wallNow(); const range = { start: Math.min(Math.max(end - maxRecordingTimeMs, epoch), end), end }; return withTimeRange(backend.snapshot(frozen), range); @@ -287,7 +317,7 @@ export function createChunkCaptureStore( backend.removeGeneration(generation); parts = []; totalBytes = 0; - visualPart = undefined; + openVisual.clear(); epoch = clock.wallNow(); dataPart = openNewPart(epoch, 'data'); }, diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 470bf467..17d99d29 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -54,6 +54,8 @@ export interface CaptureDataEntry { data: unknown; /** True for the first record of a self-contained base (see `StoredEntry.anchor`). Absent = false. */ readonly anchor?: boolean; + /** The visual source the record came from (see `StoredEntry.lane`). Absent = the unkeyed source. */ + lane?: string; /** Serialize this entry to its stored string form. */ serialize(): string; /** Populate this entry from its stored string form (mutates `this`; Android instance deserialize). */ @@ -86,6 +88,13 @@ export interface StoredEntry { * Exists because Durable Objects for different tenants share one isolate, one client and one capture ring */ owner?: string; + /** + * The visual SOURCE this record belongs to (Electron main: the id of the window whose recorder produced it), kept + * out-of-band like `timestamp`. Each source has its own visual lane: its own bases, eviction and reset, and its own + * member in the bundle. Absent = the single unkeyed source (every runtime with one screen). Only the visual types + * (`replay`, `video`) are routed on it. + */ + lane?: string; } /** diff --git a/packages/core/src/disk-pressure-backend.ts b/packages/core/src/disk-pressure-backend.ts index 2c1d3a5f..5b2603da 100644 --- a/packages/core/src/disk-pressure-backend.ts +++ b/packages/core/src/disk-pressure-backend.ts @@ -555,8 +555,12 @@ export function withDiskPressure( snapshot(frozen: readonly FrozenPart[]): CaptureSnapshot { // The counts are the boundary of each half at THIS instant (the store's own count is their sum). const durableParts: FrozenPart[] = []; - for (const { ref } of frozen) { - durableParts.push({ ref, count: parts.get(keyOf(ref))?.durableCount ?? 0 }); + for (const { ref, lane } of frozen) { + durableParts.push({ + ref, + count: parts.get(keyOf(ref))?.durableCount ?? 0, + ...(lane !== undefined ? { lane } : {}), + }); } if (shadowed === 0) { // One durable snapshot PER PART, so that a part whose read throws costs only that part (the rest of the window @@ -576,7 +580,7 @@ export function withDiskPressure( routes: Map; } const deadMedium = { failed: false, threw: false }; // shared by every part of this snapshot (see guardedRead) - const reads: FrozenPartRead[] = frozen.map(({ ref }, index) => { + const reads: FrozenPartRead[] = frozen.map(({ ref, lane }, index) => { const state = parts.get(keyOf(ref)); const fromDurable = guardedRead( snapshotOrEmpty([durableParts[index] as FrozenPart], deadMedium), @@ -587,7 +591,9 @@ export function withDiskPressure( } return { fromDurable, - fromShadow: shadow.snapshot([{ ref, count: state.shadowCount }]), + fromShadow: shadow.snapshot([ + { ref, count: state.shadowCount, ...(lane !== undefined ? { lane } : {}) }, + ]), routes: state.routes, }; }); diff --git a/packages/core/src/disk-pressure-store.test.ts b/packages/core/src/disk-pressure-store.test.ts index dafee4b6..2a25ad5c 100644 --- a/packages/core/src/disk-pressure-store.test.ts +++ b/packages/core/src/disk-pressure-store.test.ts @@ -1912,3 +1912,17 @@ describe('withDiskPressure — a part that keeps throwing', () => { expect(calls).toEqual(['unpin0', 'unpin1', 'unpin2', 'release0', 'release1', 'release2']); }); }); + +describe('disk pressure — visual source lanes survive both halves', () => { + it("serves each window's replay with its lane whether it landed on the medium or only in the shadow", async () => { + const { pressure, store } = setup(); + store.add({ ...rec('replay', 1, 'a-on-disk'), lane: 'a' }); + pressure.observe(5 * MB); // low: replay now stays in memory only + store.add({ ...rec('replay', 2, 'b-shadow'), lane: 'b' }); + const replay = (await drain(store)).get('replay') ?? []; + expect(replay.map((r) => [r.serialized, r.lane])).toEqual([ + ['a-on-disk', 'a'], + ['b-shadow', 'b'], + ]); + }); +}); diff --git a/packages/core/src/file-chunk-backend.test.ts b/packages/core/src/file-chunk-backend.test.ts index 73c2ee20..cc9546e0 100644 --- a/packages/core/src/file-chunk-backend.test.ts +++ b/packages/core/src/file-chunk-backend.test.ts @@ -233,3 +233,25 @@ describe('createFileChunkBackend — probeWrite', () => { expect(flushNow).not.toHaveBeenCalled(); }); }); + +describe('createFileChunkBackend — visual source lane', () => { + it('attaches the frozen part lane to every record it reads back', async () => { + const b = createFileChunkBackend(createInMemoryChunkStorage(), { generation: 5 }); + b.openPart(ref(5, 0), 0); + b.appendEntry(ref(5, 0), rec('w', 'replay', 1)); + b.openPart(ref(5, 1), 0); + b.appendEntry(ref(5, 1), rec('u', 'replay', 2)); + const snap = b.snapshot([ + { ref: ref(5, 0), count: 1, lane: 'win-3' }, + { ref: ref(5, 1), count: 1 }, + ]); + const seen: Array<[string, string | undefined]> = []; + for await (const r of snap.stream()) { + seen.push([r.serialized, r.lane]); + } + expect(seen).toEqual([ + ['w', 'win-3'], + ['u', undefined], + ]); + }); +}); diff --git a/packages/core/src/file-chunk-backend.ts b/packages/core/src/file-chunk-backend.ts index cd60bf08..aeb7d14c 100644 --- a/packages/core/src/file-chunk-backend.ts +++ b/packages/core/src/file-chunk-backend.ts @@ -140,7 +140,7 @@ export function createFileChunkBackend( // EAGER read at snapshot() time: the data files hold exactly the records captured so far (add is // sync), so reading them now freezes the view — post-snapshot appends land after and are excluded. const frozen: StoredEntry[] = []; - for (const { ref } of parts) { + for (const { ref, lane } of parts) { for (const file of storage.files(ref.generation, ref.number)) { if (file === META_FILE) { continue; @@ -159,7 +159,12 @@ export function createFileChunkBackend( if (!Number.isFinite(timestamp)) { continue; // skip a corrupt timestamp } - frozen.push({ type, timestamp, serialized: line.slice(tab + 1) }); + frozen.push({ + type, + timestamp, + serialized: line.slice(tab + 1), + ...(lane !== undefined ? { lane } : {}), + }); } } } From a7f50acc3f4f688ebacb2af9664341d26644bf3b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 13:13:14 +0500 Subject: [PATCH 18/33] feat(electron,node): each window's replay is its own lane in main; a byte-cap reset restarts only that window (per-window replay L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receiver tags a renderer's replay with its webContents id, main keeps a visual lane per window, onVisualReset(lane) is paced per window and checkout(windowId) reaches only that renderer. An unkeyed reset still asks every renderer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/per-window-replay.md | 18 +++-- packages/electron/src/launch-main.test.ts | 89 +++++++++++++++++++++ packages/electron/src/launch-main.ts | 34 ++++++-- packages/electron/src/main-control.test.ts | 47 +++++++++++ packages/electron/src/main-control.ts | 20 ++++- packages/electron/src/main-receiver.test.ts | 26 +++++- packages/electron/src/main-receiver.ts | 4 + packages/node/src/launch.ts | 5 +- 9 files changed, 226 insertions(+), 19 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2efdeef9..10a5265e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Still open:** per-window visual lanes in the Electron main (see above); the ~20 MB assembler residual of the browser export is unexplained. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); crash recovery exports one unkeyed replay (part meta has no lane); the ~20 MB assembler residual of the browser export is unexplained. diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index 49571e9d..672dfb24 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -29,11 +29,19 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of ## Slices -* **L0** core: `lane` on records, per-lane visual state in the chunk store, per-lane reset callback (memory backend). -* **L1** file, IndexedDB and disk-pressure backends attach the lane on read. -* **L2** reifier + assembler: members per `(type, lane)`, naming, manifest attrs, per-member encoder sessions. -* **L3** Electron: window id as lane, per-window `checkout` and per-lane pacing. -* **L4** pixel video per display (compositor); viewer support for secondary members. +* **L0 DONE 2026-11-01** core: `lane` on records, per-lane visual state in the chunk store, per-lane reset callback. +* **L1 DONE 2026-11-01** file, IndexedDB and disk-pressure backends attach the lane on read (memory keeps it on the record). +* **L2 DONE 2026-11-01** reifier + assembler: one stream-encoded member per lane (`replay.bin`, `replay.1.bin`, ..., + manifest `attrs.window`), per-member encoder sessions; a lane that produced nothing leaves no member and does not take + the canonical name; non-stream-encoded types ignore the lane. +* **L3 DONE 2026-11-01** Electron: the receiver tags a renderer's replay with its `webContents` id; `onVisualReset(lane)` + paces and restarts ONLY that window (`checkout(windowId)`, a gate per window; a window with no registered renderer spends + no budget); an unkeyed reset still asks everyone. +* **L4 open** pixel video per display (compositor in main), and viewer support for secondary members. + +Known limits of what shipped: a recovered prior generation (crash recovery) exports as one unkeyed replay, because part +meta does not persist the lane; the viewer still reads only `replay.bin`, which is the first window to produce a base, not +necessarily the key window. Open (needs the viewer/mobile side): the exact secondary member names and manifest attrs are chosen here (`replay..bin`, `attrs.window`), not taken from a mobile convention; they must be confirmed against the viewer before the viewer reads them. diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index acbe3315..6f411ca0 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -157,6 +157,95 @@ describe('launchMain', () => { expect(f.stop).toHaveBeenCalledTimes(1); // original node stop still runs }); + describe('a visual reset of one window (per-window lanes)', () => { + const setup = () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + let now = 0; + launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + clock: { wallNow: () => now, monotonicNow: () => now }, + } as never); + const a = fakeSender(1); + const b = fakeSender(2); + for (const r of [a, b]) { + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r }, encodeHello()); + r.sent.length = 0; + } + const reset = (lane?: string): void => + ( + f.received?.options as unknown as { onVisualReset: (lane?: string) => void } + ).onVisualReset(lane); + return { a, b, reset, at: (t: number) => (now = t) }; + }; + const asked = (r: { sent: Array<{ raw: string }> }) => + r.sent.map((m) => decodeControl(m.raw)?.command); + + it('restarts only the window whose lane lost its bases', () => { + const { a, b, reset } = setup(); + reset('2'); + expect(asked(a)).toEqual([]); + expect(asked(b)).toEqual(['checkout']); + }); + + it('paces each window on its own: window 2 backing off does not delay window 1', () => { + const { a, b, reset, at } = setup(); + reset('2'); + at(1_000); + reset('2'); // inside window 2's quiet period + reset('1'); // window 1 has never asked + expect(asked(b)).toEqual(['checkout']); + expect(asked(a)).toEqual(['checkout']); + }); + + it('a window with no registered renderer spends no budget and asks nobody', () => { + const { a, b, reset } = setup(); + reset('99'); + expect([...asked(a), ...asked(b)]).toEqual([]); + }); + + it('a reset of a window that has not registered yet leaves its budget unspent', () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + let now = 0; + launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + clock: { wallNow: () => now, monotonicNow: () => now }, + } as never); + const reset = (lane: string): void => + ( + f.received?.options as unknown as { onVisualReset: (lane?: string) => void } + ).onVisualReset(lane); + reset('9'); // window 9 is not there yet + now = 1_000; + const late = fakeSender(9); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: late }, encodeHello()); + late.sent.length = 0; + reset('9'); // inside what would have been the quiet period had the first call spent it + expect(asked(late)).toEqual(['checkout']); + }); + + it('an unkeyed reset still asks every renderer', () => { + const { a, b, reset } = setup(); + reset(); + expect(asked(a)).toEqual(['checkout']); + expect(asked(b)).toEqual(['checkout']); + }); + + it("hands the lane to the caller's own callback", () => { + const own = vi.fn(); + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + launchMain('tok', { ipcMain: ipc.ipcMain, launch: f.launch, onVisualReset: own } as never); + (f.received?.options as unknown as { onVisualReset: (lane?: string) => void }).onVisualReset( + '5', + ); + expect(own).toHaveBeenCalledWith('5'); + }); + }); + describe('a visual reset in the main store (every replay base shed under the byte cap)', () => { const setup = (over: Record = {}) => { const ipc = fakeIpcMain(); diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index aee23df7..99af1ce4 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -483,8 +483,10 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee // Main's store is the one with the byte cap, and the renderers' recorders stream into it. When it sheds every replay base // the renderers are told to take a fresh full snapshot (control `checkout`); without that the stream has no base until a // renderer's next periodic checkout. Paced by the same backoff the browser applies to its own store, and released by stop. - let checkoutRenderers: (() => void) | undefined; - let mainControl: { readonly rendererCount: number } | undefined; + let checkoutRenderers: ((windowId?: number) => void) | undefined; + let mainControl: + | { readonly rendererCount: number; hasRenderer(windowId: number): boolean } + | undefined; const callerReset = nodeOptions.onVisualReset; const resetClock = nodeOptions.clock ?? createSystemClock(); const gateReset = createVisualResetGate({ @@ -493,13 +495,32 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee ready: () => (mainControl?.rendererCount ?? 0) > 0, restart: () => checkoutRenderers?.(), }); - nodeOptions.onVisualReset = (): void => { + // A window's lane (its webContents id as text) is paced on its own, so one window backing off never delays another. + const windowGates = new Map void>(); + const gateWindow = (lane: string): (() => void) => { + let gate = windowGates.get(lane); + if (gate === undefined) { + const windowId = Number(lane); + gate = createVisualResetGate({ + now: () => resetClock.monotonicNow(), + ready: () => mainControl?.hasRenderer(windowId) === true, + restart: () => checkoutRenderers?.(windowId), + }); + windowGates.set(lane, gate); + } + return gate; + }; + nodeOptions.onVisualReset = (lane?: string): void => { try { - callerReset?.(); // the caller's own callback keeps working, and cannot cost the restart + callerReset?.(...(lane === undefined ? [] : [lane])); // the caller's own callback keeps working, and cannot cost the restart } catch (error) { reportGuarded(nodeOptions.onError, error); } - gateReset(); + if (lane === undefined) { + gateReset(); + } else { + gateWindow(lane)(); + } }; const { client, internals } = (launch ?? launchCore)(appToken, nodeOptions); @@ -639,7 +660,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee }); control.start(); mainControl = control; - checkoutRenderers = (): void => control.checkout(); + checkoutRenderers = (windowId?: number): void => control.checkout(windowId); // Arm the key-window stage and forward every change to the renderers whose answer moved (released in the wrapped stop). const offKeyWindow = keyWindow === undefined @@ -684,6 +705,7 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee offBlackout(); offKeyWindow?.(); checkoutRenderers = undefined; // a reset after stop has nobody to tell + windowGates.clear(); mainControl = undefined; control.stop(); // broadcast stop to renderers + remove the hello listener receiver.stop(); diff --git a/packages/electron/src/main-control.test.ts b/packages/electron/src/main-control.test.ts index ddb1b44e..f9f2dec9 100644 --- a/packages/electron/src/main-control.test.ts +++ b/packages/electron/src/main-control.test.ts @@ -1117,3 +1117,50 @@ describe('stop() releases the destroyed subscriptions', () => { expect(a.listeners).toHaveLength(0); }); }); + +describe('checkout(windowId) — one window only', () => { + const setup = () => { + const ipc = fakeIpcMain(); + const control = createElectronMainControl({ ipcMain: ipc.ipcMain, sessionId: 's1' }); + control.start(); + const a = fakeSender(1); + const b = fakeSender(2); + for (const sender of [a.sender, b.sender]) { + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender }, encodeHello()); + } + return { control, a, b }; + }; + const after = (r: { sent: Array<{ raw: string }> }) => + r.sent.slice(1).map((m) => decodeControl(m.raw)?.command); + + it('asks only the renderer of that window', () => { + const { control, a, b } = setup(); + control.checkout(2); + expect(after(a)).toEqual([]); + expect(after(b)).toEqual(['checkout']); + }); + + it('asks nobody for a window that is not registered', () => { + const { control, a, b } = setup(); + control.checkout(99); + expect([...after(a), ...after(b)]).toEqual([]); + }); + + it('says whether a window has a registered renderer', () => { + const { control } = setup(); + expect(control.hasRenderer(1)).toBe(true); + expect(control.hasRenderer(99)).toBe(false); + }); + + it('does not count a destroyed renderer as registered', () => { + const ipc = fakeIpcMain(); + const control = createElectronMainControl({ ipcMain: ipc.ipcMain, sessionId: 's1' }); + control.start(); + const gone = { ...fakeSender(4), destroyed: false }; + const sender = Object.assign(gone.sender, { isDestroyed: () => gone.destroyed }); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender }, encodeHello()); + expect(control.hasRenderer(4)).toBe(true); + gone.destroyed = true; + expect(control.hasRenderer(4)).toBe(false); + }); +}); diff --git a/packages/electron/src/main-control.ts b/packages/electron/src/main-control.ts index 8b91e108..cd7cb515 100644 --- a/packages/electron/src/main-control.ts +++ b/packages/electron/src/main-control.ts @@ -93,7 +93,9 @@ export interface ElectronMainControl { * Ask every renderer to restart its replay recorder for a fresh full snapshot: main's store shed every replay base under * its byte cap, so what the renderers stream next would otherwise have no base until their next periodic checkout. */ - checkout(): void; + checkout(windowId?: number): void; + /** Whether a renderer with this `webContents` id is registered. */ + hasRenderer(windowId: number): boolean; /** * The key window may have changed: re-evaluate every registered renderer and send `window-key` to those whose answer * differs from what they were last told. A no-op without the `keyWindow` option. @@ -228,9 +230,13 @@ export function createElectronMainControl( const broadcast = ( command: 'pause' | 'resume' | 'flush' | 'stop' | 'blackout-start' | 'blackout-end' | 'checkout', + only?: number, ): void => { const raw = encodeControl({ command }); for (const sender of [...renderers]) { + if (only !== undefined && sender.id !== only) { + continue; + } // Prune on the way past. `destroyed` is the primary signal, but it is an EVENT — a missed or absent // one would keep the corpse forever, and broadcasting is where we would notice anyway. if (sender.isDestroyed?.() === true) { @@ -360,8 +366,16 @@ export function createElectronMainControl( blackoutEnd(): void { broadcast('blackout-end'); }, - checkout(): void { - broadcast('checkout'); + checkout(windowId?: number): void { + broadcast('checkout', windowId); + }, + hasRenderer(windowId: number): boolean { + for (const sender of renderers) { + if (sender.id === windowId && sender.isDestroyed?.() !== true) { + return true; + } + } + return false; }, keyWindowChanged: rediffKey, rendererGone(id: number): void { diff --git a/packages/electron/src/main-receiver.test.ts b/packages/electron/src/main-receiver.test.ts index f1794978..9343a550 100644 --- a/packages/electron/src/main-receiver.test.ts +++ b/packages/electron/src/main-receiver.test.ts @@ -71,8 +71,8 @@ describe('createElectronMainReceiver', () => { ipc.emit(BUGSEE_STREAM_CHANNEL, { sender: { id: 1 } }, anchored); ipc.emit(BUGSEE_STREAM_CHANNEL, { sender: { id: 1 } }, wire('replay', 6, '{}')); expect(s.added).toEqual([ - { type: 'replay', timestamp: 5, serialized: '{}', anchor: true }, - { type: 'replay', timestamp: 6, serialized: '{}' }, + { type: 'replay', timestamp: 5, serialized: '{}', anchor: true, lane: '1' }, + { type: 'replay', timestamp: 6, serialized: '{}', lane: '1' }, ]); }); @@ -211,3 +211,25 @@ describe('createElectronMainReceiver — report routing + listener containment', expect(String(errors[0])).toContain('join boom'); }); }); + +describe('createElectronMainReceiver — one visual lane per window', () => { + const run = (event: IpcMainEventLike, type: string) => { + const ipc = fakeIpcMain(); + const s = fakeStore(); + createElectronMainReceiver({ ipcMain: ipc.ipcMain, store: s.store }).start(); + ipc.emit(BUGSEE_STREAM_CHANNEL, event, wire(type, 1, '{}')); + return s.added[0]; + }; + + it('tags a renderer replay record with its window id', () => { + expect(run({ sender: { id: 7 } }, 'replay')).toMatchObject({ type: 'replay', lane: '7' }); + }); + + it('leaves data records unkeyed (only the visual lane is split by window)', () => { + expect(run({ sender: { id: 7 } }, 'log')).not.toHaveProperty('lane'); + }); + + it('leaves a replay record unkeyed when the sender cannot be identified', () => { + expect(run({}, 'replay')).not.toHaveProperty('lane'); + }); +}); diff --git a/packages/electron/src/main-receiver.ts b/packages/electron/src/main-receiver.ts index 9286c2b6..c3566e2c 100644 --- a/packages/electron/src/main-receiver.ts +++ b/packages/electron/src/main-receiver.ts @@ -80,6 +80,10 @@ export function createElectronMainReceiver( timestamp: decoded.timestamp, serialized: decoded.payload, ...(decoded.anchor === true ? { anchor: true } : {}), + // Each window's replay is its own visual lane in the main store (its bases, eviction, reset and bundle member). + ...(decoded.type === 'replay' && event.sender?.id !== undefined + ? { lane: String(event.sender.id) } + : {}), }); options.onEntry?.(decoded, windowId); } catch (error) { diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index c37f796b..83a87ffc 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -448,9 +448,10 @@ export interface BugseeLaunchOptions { /** * Called when the byte cap forced every visual (replay) base out of the store, so a recorder that feeds it must take a * fresh snapshot. For a host whose recorders live elsewhere (the Electron main, whose renderers stream into this store); - * it is not called for a `captureStore` you supply (that store is yours). Called at most once per drop, off the write path. + * it is not called for a `captureStore` you supply (that store is yours). Called at most once per drop, off the write path, + * with the lane (an Electron window) that lost its bases, or no argument for the unkeyed lane. */ - onVisualReset?: () => void; + onVisualReset?: (lane?: string) => void; /** System probe for the environment envelope. Default realSystemProbe. */ systemProbe?: SystemProbe; /** From 9222feda6291434d92a94cfe1c29dd5ee35cc9c5 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 13:17:45 +0500 Subject: [PATCH 19/33] fix(core,browser-utils): a laned member keeps the type's manifest attrs; a released IndexedDB read stops between parts (review of per-window replay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/per-window-replay.md | 5 ++- .../src/idb-chunk-backend.test.ts | 32 +++++++++++++++++++ .../browser-utils/src/idb-chunk-backend.ts | 3 ++ .../core/src/bundle-stream-assembler.test.ts | 18 +++++++++++ packages/core/src/bundle-stream-assembler.ts | 5 +-- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index 672dfb24..20646cbb 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -41,7 +41,10 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of Known limits of what shipped: a recovered prior generation (crash recovery) exports as one unkeyed replay, because part meta does not persist the lane; the viewer still reads only `replay.bin`, which is the first window to produce a base, not -necessarily the key window. +necessarily the key window. A window that closes leaves its last base segment in the store until the byte cap sheds it (it is +the oldest visual data, so it goes first): time eviction keeps the newest base per lane, and an idle window is +indistinguishable from a closed one without a signal from main, so the lane is not dropped on `rendererGone` yet (open: +a store `dropLane`). A stream-encoded lane keeps the type's own manifest attrs (a laned video keeps `format`). Open (needs the viewer/mobile side): the exact secondary member names and manifest attrs are chosen here (`replay..bin`, `attrs.window`), not taken from a mobile convention; they must be confirmed against the viewer before the viewer reads them. diff --git a/packages/browser-utils/src/idb-chunk-backend.test.ts b/packages/browser-utils/src/idb-chunk-backend.test.ts index 3996ff3a..9eeae072 100644 --- a/packages/browser-utils/src/idb-chunk-backend.test.ts +++ b/packages/browser-utils/src/idb-chunk-backend.test.ts @@ -1766,4 +1766,36 @@ describe('createIdbChunkBackend — stream() reads one part at a time', () => { ).get('replay'); expect(drained?.[0]?.lane).toBe('win-3'); }); + + it('a release after the first part was served ends the stream there, without reading the next', async () => { + const { b, reads, frozen } = await setup(); + const snap = b.snapshot(frozen.slice(0, 2)); + const iterator = snap.stream(); + await iterator.next(); // a0 + await iterator.next(); // b0: part 0 is served + snap.release(); + expect((await iterator.next()).done).toBe(true); + expect(reads).toEqual([dataPrefixFor(5, 0)]); + }); + + it('a read that fails in a later part throws there, after the earlier part was served', async () => { + const inner = keyed(); + const store: AsyncKeyedStore = { + ...inner, + readPrefix: (prefix) => + prefix === dataPrefixFor(5, 1) + ? Promise.reject(new Error('part 1 unreadable')) + : inner.readPrefix(prefix), + }; + const b = createIdbChunkBackend(store, { generation: 5, cleanOtherGenerations: false }); + for (const n of [0, 1]) { + b.openPart(ref(5, n), n); + b.appendEntry(ref(5, n), rec(`a${n}`, 'log', n)); + b.closePart(ref(5, n), n + 1, 2); + } + await b.flush?.(); + const iterator = b.snapshot([0, 1].map((n) => ({ ref: ref(5, n), count: 1 }))).stream(); + expect((await iterator.next()).value).toMatchObject({ serialized: 'a0' }); + await expect(iterator.next()).rejects.toThrow('part 1 unreadable'); + }); }); diff --git a/packages/browser-utils/src/idb-chunk-backend.ts b/packages/browser-utils/src/idb-chunk-backend.ts index 3ec97dfb..a436bb5f 100644 --- a/packages/browser-utils/src/idb-chunk-backend.ts +++ b/packages/browser-utils/src/idb-chunk-backend.ts @@ -544,6 +544,9 @@ export function createIdbChunkBackend( ); } for (const [index, { ref, count, lane }] of frozen.entries()) { + if (released) { + return; // the consumer let go between parts: read nothing more + } const records = await until( withDeadline(store.readPrefix(dataPrefix(ref.generation, ref.number)), readTimeoutMs), ); diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 04bad3af..1e99075d 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -1188,4 +1188,22 @@ describe('assembleBundleStream — one stream-encoded member per visual source l const files = members(await bundle.body.bytes()); expect(JSON.parse(strFromU8(files['logs.json'] as Uint8Array))).toEqual(['l', 'm']); }); + + it("keeps the type's own manifest attrs next to the window (a laned video keeps its format)", async () => { + const encoder = { begin: () => ({ write: () => [strToU8('v')], end: () => [] }) }; + const video = Object.assign(new CaptureDataEntryBase('video', NOW3 - 100, 'f'), { lane: 'd1' }); + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + from([video]), + context({ streamEncoders: { video: encoder as never } }), + ); + const files = members(await bundle.body.bytes()); + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + files: Array<{ type: string; attrs?: Record }>; + }; + expect(manifest.files.find((f) => f.type === 'video')?.attrs).toEqual({ + format: 'webm', + window: 'd1', + }); + }); }); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index 413ed3f5..4e647506 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -323,10 +323,11 @@ async function buildInto( stream.file.push(new Uint8Array(0), true); } const name = stream?.name ?? fileNameForType(type); + const entry = manifestEntryForType(type); files.push({ - ...manifestEntryForType(type), + ...entry, filename: name, - ...(stream?.lane !== undefined ? { attrs: { window: stream.lane } } : {}), + ...(stream?.lane !== undefined ? { attrs: { ...entry.attrs, window: stream.lane } } : {}), }); dataNames.push(name); const member = streamed.get(key); From 41c901e32551e895b76fbb217d667b605eee610a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 13:56:51 +0500 Subject: [PATCH 20/33] feat(core,browser-utils): the window lane is persisted in the part meta, so recovered crashes rebuild one replay per window (per-window replay L5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openPart takes the lane; file and IndexedDB meta store it; the disk-pressure wrapper passes it on when it opens a deferred part; capture recovery and native-crash recovery freeze each part with it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/per-window-replay.md | 15 +++++--- .../src/idb-chunk-backend.test.ts | 15 ++++++++ .../browser-utils/src/idb-chunk-backend.ts | 27 +++++++++++--- packages/core/src/capture-recovery.test.ts | 35 +++++++++++++++++++ packages/core/src/capture-recovery.ts | 1 + packages/core/src/chunk-backend.ts | 4 ++- packages/core/src/chunk-capture-store.test.ts | 13 +++++++ packages/core/src/chunk-capture-store.ts | 6 +++- packages/core/src/disk-pressure-backend.ts | 11 ++++-- packages/core/src/disk-pressure-store.test.ts | 18 ++++++++++ packages/core/src/file-chunk-backend.test.ts | 13 +++++++ packages/core/src/file-chunk-backend.ts | 28 +++++++++++---- .../core/src/memory-chunk-backend.test.ts | 13 +++++++ packages/core/src/memory-chunk-backend.ts | 3 +- .../core/src/native-crash-recovery.test.ts | 21 +++++++++++ packages/core/src/native-crash-recovery.ts | 1 + 17 files changed, 205 insertions(+), 21 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 10a5265e..6affd71a 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); crash recovery exports one unkeyed replay (part meta has no lane); the ~20 MB assembler residual of the browser export is unexplained. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); the ~20 MB assembler residual of the browser export is unexplained. diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index 20646cbb..41aa732d 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -21,7 +21,7 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of |---|---| | `StoredEntry` | optional out-of-band `lane?: string` (a source key: the window id as text). Routing needs no deserialize. Absent = the single unkeyed lane (every runtime but Electron main; behavior unchanged). | | Chunk store | a visual lane per key: its own open part, its own base accounting. Time eviction keeps the newest anchored part at or before the window start PER KEY. Byte-cap shedding sheds the lane that holds the OLDEST visual bytes; `onVisualReset(lane)` names the lane whose bases are all gone. | -| `FrozenPart` / backends | `FrozenPart.lane` carries the key; every backend attaches it to the records it yields (memory keeps it on the record; file, IndexedDB and the disk-pressure wrapper attach it from the frozen part). Part meta is NOT extended: a recovered prior generation exports as one unkeyed stream (documented limit). | +| `FrozenPart` / backends | `FrozenPart.lane` carries the key; every backend attaches it to the records it yields (memory keeps it on the record; file, IndexedDB and the disk-pressure wrapper attach it from the frozen part). Part meta persists the lane (`openPart(ref, start, lane?)`; `PartMeta.lane`; file meta `l`, IndexedDB meta `l`), so both recovery paths rebuild one member per window. | | `CaptureDataEntry` | `lane?: string` copied from the stored record by the reifier. | | Export | members are grouped by `(type, lane)`. The first lane (by first record) keeps the canonical name (`replay.bin`); later lanes get `replay..bin` with a manifest entry `{type: 'replay', attrs: {window: }}`. Each member has its own stream encoder session (orphan trim and clamp are per window). A viewer that reads only `replay.bin` still gets a coherent single-window replay. | | Electron main | tags each renderer record with its window id as `lane`; `onVisualReset(lane)` restarts ONLY that window's recorder (`checkout` carries the window id; the gate paces per lane). | @@ -39,12 +39,17 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of no budget); an unkeyed reset still asks everyone. * **L4 open** pixel video per display (compositor in main), and viewer support for secondary members. -Known limits of what shipped: a recovered prior generation (crash recovery) exports as one unkeyed replay, because part -meta does not persist the lane; the viewer still reads only `replay.bin`, which is the first window to produce a base, not +* **L5 DONE 2026-11-01** the lane is persisted in the part meta (all backends and the disk-pressure wrapper), and capture + recovery and native-crash recovery freeze each part with it: a crashed multi-window session rebuilds one replay per window. + +Known limits of what shipped: the viewer still reads only `replay.bin`, which is the first window to produce a base, not necessarily the key window. A window that closes leaves its last base segment in the store until the byte cap sheds it (it is the oldest visual data, so it goes first): time eviction keeps the newest base per lane, and an idle window is indistinguishable from a closed one without a signal from main, so the lane is not dropped on `rendererGone` yet (open: a store `dropLane`). A stream-encoded lane keeps the type's own manifest attrs (a laned video keeps `format`). -Open (needs the viewer/mobile side): the exact secondary member names and manifest attrs are chosen here (`replay..bin`, -`attrs.window`), not taken from a mobile convention; they must be confirmed against the viewer before the viewer reads them. +Viewer (checked 2026-11-01, `viewer/src/app/features/recording/shared/recording-session.service.ts`): it takes +`files.find(type === 'replay')`, i.e. only the FIRST replay entry of the manifest, so extra windows are ignored today and +the shown window is the first in manifest order. For video it already maps EVERY `video` entry and reads +`attrs.displayId` (`manifestEntryDisplayId`; legacy `display_id`; missing = 0), so the pixel half uses `attrs.displayId`. +Replay members use `attrs.window` (the viewer has no window concept yet): a viewer change is needed to pick or switch windows. diff --git a/packages/browser-utils/src/idb-chunk-backend.test.ts b/packages/browser-utils/src/idb-chunk-backend.test.ts index 9eeae072..fa304e01 100644 --- a/packages/browser-utils/src/idb-chunk-backend.test.ts +++ b/packages/browser-utils/src/idb-chunk-backend.test.ts @@ -1799,3 +1799,18 @@ describe('createIdbChunkBackend — stream() reads one part at a time', () => { await expect(iterator.next()).rejects.toThrow('part 1 unreadable'); }); }); + +describe('createIdbChunkBackend — part lane in the durable meta', () => { + it('openPart(lane) is listed with its lane, open and after closePart; no lane lists none', async () => { + const b = createIdbChunkBackend(keyed(), { generation: 5, cleanOtherGenerations: false }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.openPart(ref(5, 1), 1); + await b.flush?.(); + expect((await b.listParts(5))[0]?.lane).toBe('win-3'); + b.closePart(ref(5, 0), 2, 7); + await b.flush?.(); + const parts = await b.listParts(5); + expect(parts[0]).toMatchObject({ number: 0, end: 2, byteSize: 7, lane: 'win-3' }); + expect(parts[1]).not.toHaveProperty('lane'); + }); +}); diff --git a/packages/browser-utils/src/idb-chunk-backend.ts b/packages/browser-utils/src/idb-chunk-backend.ts index a436bb5f..b21bdd01 100644 --- a/packages/browser-utils/src/idb-chunk-backend.ts +++ b/packages/browser-utils/src/idb-chunk-backend.ts @@ -78,6 +78,7 @@ interface StoredMeta { readonly s: number; readonly e: number | null; readonly b: number; + readonly l?: string; } interface StoredData { readonly t: number; @@ -345,6 +346,7 @@ export function createIdbChunkBackend( // leaves the map when its write lands, and with its part; what a crash recovers is, by definition, only what landed. const unlanded = new Map>(); const openStarts = new Map(); // chunkKey → start, for the closePart meta rewrite + const openLanes = new Map(); // chunkKey → visual lane, for the closePart meta rewrite const seqByChunk = new Map(); // chunkKey → next data sequence number // Snapshot pinning: a part pinned by ≥1 live snapshot is not physically deleted until released. @@ -362,12 +364,22 @@ export function createIdbChunkBackend( start: number, end: number | null, byteSize: number, + lane?: string, ): void => { // NOT a health signal (`write` false): a ~60-byte meta put succeeds on a medium that still refuses real records // (a per-origin quota, a data-full filesystem with free metadata), so counting it released the disk-pressure pin // on every tick. Only a successful DATA put (a record, or the pressure probe's record) says writes work again. enqueue(() => - store.put(metaKey(gen, number), enc({ n: number, s: start, e: end, b: byteSize })), + store.put( + metaKey(gen, number), + enc({ + n: number, + s: start, + e: end, + b: byteSize, + ...(lane !== undefined ? { l: lane } : {}), + }), + ), ); }; @@ -416,6 +428,7 @@ export function createIdbChunkBackend( start: m.s, end: m.e ?? undefined, byteSize: m.b, + ...(typeof m.l === 'string' ? { lane: m.l } : {}), }; }) .sort((a, b) => a.number - b.number), @@ -424,10 +437,13 @@ export function createIdbChunkBackend( return { generation, - openPart(ref: PartRef, start: number): void { + openPart(ref: PartRef, start: number, lane?: string): void { openStarts.set(chunkKey(ref), start); + if (lane !== undefined) { + openLanes.set(chunkKey(ref), lane); + } seqByChunk.set(chunkKey(ref), 0); - writeMeta(ref.generation, ref.number, start, null, 0); + writeMeta(ref.generation, ref.number, start, null, 0, lane); }, appendEntry(ref: PartRef, record: StoredEntry): number { @@ -458,12 +474,15 @@ export function createIdbChunkBackend( const key = chunkKey(ref); const start = openStarts.get(key) ?? 0; openStarts.delete(key); - writeMeta(ref.generation, ref.number, start, end, byteSize); + const lane = openLanes.get(key); + openLanes.delete(key); + writeMeta(ref.generation, ref.number, start, end, byteSize, lane); }, removePart(ref: PartRef): void { const key = chunkKey(ref); openStarts.delete(key); + openLanes.delete(key); seqByChunk.delete(key); unlanded.delete(key); // a snapshot already holding the map keeps its own reference if ((pins.get(key) ?? 0) > 0) { diff --git a/packages/core/src/capture-recovery.test.ts b/packages/core/src/capture-recovery.test.ts index 532fd20f..39327f13 100644 --- a/packages/core/src/capture-recovery.test.ts +++ b/packages/core/src/capture-recovery.test.ts @@ -868,3 +868,38 @@ describe('createMarkerAwareBundleReplay', () => { }); }); }); + +describe('recoverReports — a prior generation with several windows', () => { + it('rebuilds one replay member per window from the lanes persisted in the part meta', async () => { + const storage = createInMemoryChunkStorage(); + const seed = createFileChunkBackend(storage, { generation: 100, cleanOtherGenerations: false }); + const replay = (timestamp: number, data: string): StoredEntry => ({ + type: 'replay', + timestamp, + serialized: JSON.stringify({ timestamp, data }), + }); + seed.openPart(ref(100, 0), 1, 'w1'); + seed.appendEntry(ref(100, 0), replay(1, 'a1')); + seed.closePart(ref(100, 0), 2, 0); + seed.openPart(ref(100, 1), 1, 'w2'); + seed.appendEntry(ref(100, 1), replay(1, 'b1')); + seed.closePart(ref(100, 1), 2, 0); + const encoder = { + begin: () => ({ + write: (e: { data: unknown }) => [Uint8Array.from(String(e.data), (c) => c.charCodeAt(0))], + end: () => [], + }), + }; + const pipe = fakePipeline(); + await recoverReports({ + backend: readBackend(storage), + currentGeneration: 999, + markers: fakeMarkers([marker('inc1', 100)]), + context: () => ({ ...baseContext(), streamEncoders: { replay: encoder } }), + uploadPipeline: pipe, + }); + const files = unzipSync((await (pipe.bundles[0] as Bundle).body) as Uint8Array); + expect(strFromU8(files['replay.bin'] as Uint8Array)).toBe('a1'); + expect(strFromU8(files['replay.1.bin'] as Uint8Array)).toBe('b1'); + }); +}); diff --git a/packages/core/src/capture-recovery.ts b/packages/core/src/capture-recovery.ts index 10c13475..51d9934c 100644 --- a/packages/core/src/capture-recovery.ts +++ b/packages/core/src/capture-recovery.ts @@ -177,6 +177,7 @@ export async function recoverReports(options: RecoverReportsOptions): Promise ({ ref: { generation, number: part.number }, count: Number.MAX_SAFE_INTEGER, // recover the whole part + ...(part.lane !== undefined ? { lane: part.lane } : {}), })); // Each marker streams the generation through its own snapshot: nothing is held between incidents, so a window // that was cap-sized costs one live export's memory, not the whole window parsed. diff --git a/packages/core/src/chunk-backend.ts b/packages/core/src/chunk-backend.ts index a82b3624..f591f002 100644 --- a/packages/core/src/chunk-backend.ts +++ b/packages/core/src/chunk-backend.ts @@ -23,6 +23,8 @@ export interface PartMeta { readonly end: number | undefined; /** Byte size of the part's stored records (in the backend's storage form). */ readonly byteSize: number; + /** The visual source the part's records belong to (see `StoredEntry.lane`); absent = unkeyed or not visual. */ + readonly lane?: string; } /** A part frozen into a snapshot: its identity + how many of its records the snapshot includes. */ @@ -39,7 +41,7 @@ export interface ChunkBackend { readonly generation: number; /** Begin a new part (and write its initial durable metadata, end = undefined). */ - openPart(part: PartRef, start: number): void; + openPart(part: PartRef, start: number, lane?: string): void; /** Append a record to a part; returns the byte size it contributed (for the store's byte cap). */ appendEntry(part: PartRef, record: StoredEntry): number; /** Finalize a part — record its end timestamp + final byte size durably. */ diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 8129c26b..886634a7 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -587,4 +587,17 @@ describe('createChunkCaptureStore — one visual lane per source key (per-window store.snapshot().release(); expect((seen[0] ?? []).map((part) => part.lane)).toEqual([undefined, 'a', undefined]); }); + + it('opens a visual part on the backend with its lane, and every other part without one', () => { + const inner = createMemoryChunkBackend(); + const opened: unknown[][] = []; + const store = createChunkCaptureStore( + { ...inner, openPart: (...args) => (opened.push(args.slice(2)), inner.openPart(...args)) }, + { clock: clockAt(0), maxRecordingTimeMs: 1_000_000 }, + ); + store.add(rec('a', 'a0', 0, true)); + store.add(rec(undefined, 'u0', 0, true)); + // [0] is the store's own first data part; then a's part, then the unkeyed visual part + expect(opened).toEqual([[], ['a'], []]); + }); }); diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index e03044f6..85892a10 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -117,7 +117,11 @@ export function createChunkCaptureStore( const openNewPart = (start: number, lane: Lane, key?: string): IndexedPart => { const ref: PartRef = { generation, number: nextNumber }; nextNumber += 1; - backend.openPart(ref, start); + if (key === undefined) { + backend.openPart(ref, start); + } else { + backend.openPart(ref, start, key); + } const part: IndexedPart = { ref, start, diff --git a/packages/core/src/disk-pressure-backend.ts b/packages/core/src/disk-pressure-backend.ts index 5b2603da..3aab195e 100644 --- a/packages/core/src/disk-pressure-backend.ts +++ b/packages/core/src/disk-pressure-backend.ts @@ -110,6 +110,8 @@ export interface DiskPressureBackendOptions { interface PartState { start: number; + /** The part's visual source lane, passed on to the medium when the part is opened there. */ + lane?: string; /** Whether the durable medium has this part (openPart was forwarded). */ opened: boolean; durableCount: number; @@ -193,7 +195,11 @@ export function withDiskPressure( const ensureOpened = (ref: PartRef, state: PartState): void => { if (!state.opened) { - durable.openPart(ref, state.start); + if (state.lane === undefined) { + durable.openPart(ref, state.start); + } else { + durable.openPart(ref, state.start, state.lane); + } state.opened = true; } }; @@ -440,11 +446,12 @@ export function withDiskPressure( const wrapped: ChunkBackend = { generation: durable.generation, - openPart(ref: PartRef, start: number): void { + openPart(ref: PartRef, start: number, lane?: string): void { shadow.openPart(ref, start); currentRef = ref; const state: PartState = { start, + ...(lane !== undefined ? { lane } : {}), opened: false, durableCount: 0, shadowCount: 0, diff --git a/packages/core/src/disk-pressure-store.test.ts b/packages/core/src/disk-pressure-store.test.ts index 2a25ad5c..90433962 100644 --- a/packages/core/src/disk-pressure-store.test.ts +++ b/packages/core/src/disk-pressure-store.test.ts @@ -1926,3 +1926,21 @@ describe('disk pressure — visual source lanes survive both halves', () => { ]); }); }); + +describe('disk pressure — the lane reaches the medium meta, also for a part opened late', () => { + it('persists a window lane for a part opened immediately and for one whose medium open was deferred', async () => { + const { storage, pressure, store } = setup(); + store.add({ ...rec('replay', 1, 'now'), lane: 'a' }); + pressure.observe(5 * MB); // low: the visual lane stays off the medium, so its part is not opened there yet + store.tick(2000); + store.add({ ...rec('replay', 3, 'late'), lane: 'b' }); + pressure.observe(30 * MB); // pressure over: the next durable write opens the deferred part + store.tick(3000); + store.add({ ...rec('replay', 4, 'after'), lane: 'b' }); + const lanes = (await createFileChunkBackend(storage, { generation: 7 }).listParts(7)).map( + (p) => p.lane, + ); + expect(lanes).toContain('a'); + expect(lanes.filter((l) => l === 'b').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/file-chunk-backend.test.ts b/packages/core/src/file-chunk-backend.test.ts index cc9546e0..e924b549 100644 --- a/packages/core/src/file-chunk-backend.test.ts +++ b/packages/core/src/file-chunk-backend.test.ts @@ -255,3 +255,16 @@ describe('createFileChunkBackend — visual source lane', () => { ]); }); }); + +describe('createFileChunkBackend — part lane in the durable meta', () => { + it('openPart(lane) is listed with its lane, open and after closePart; no lane lists none', async () => { + const b = createFileChunkBackend(createInMemoryChunkStorage(), { generation: 5 }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.openPart(ref(5, 1), 1); + expect((await b.listParts(5))[0]?.lane).toBe('win-3'); + b.closePart(ref(5, 0), 2, 7); + const parts = await b.listParts(5); + expect(parts[0]).toMatchObject({ number: 0, end: 2, byteSize: 7, lane: 'win-3' }); + expect(parts[1]).not.toHaveProperty('lane'); + }); +}); diff --git a/packages/core/src/file-chunk-backend.ts b/packages/core/src/file-chunk-backend.ts index aeb7d14c..a9e96acb 100644 --- a/packages/core/src/file-chunk-backend.ts +++ b/packages/core/src/file-chunk-backend.ts @@ -27,6 +27,7 @@ interface StoredMeta { readonly s: number; readonly e: number | null; readonly b: number; + readonly l?: string; } export interface FileChunkBackendOptions { @@ -57,8 +58,15 @@ export function createFileChunkBackend( start: number, end: number | null, byteSize: number, + lane?: string, ): void => { - const meta: StoredMeta = { n: number, s: start, e: end, b: byteSize }; + const meta: StoredMeta = { + n: number, + s: start, + e: end, + b: byteSize, + ...(lane !== undefined ? { l: lane } : {}), + }; storage.write(gen, number, META_FILE, JSON.stringify(meta)); }; @@ -72,7 +80,14 @@ export function createFileChunkBackend( if (raw !== undefined) { try { const m = JSON.parse(raw) as StoredMeta; - return { generation: gen, number, start: m.s, end: m.e ?? undefined, byteSize: m.b }; + return { + generation: gen, + number, + start: m.s, + end: m.e ?? undefined, + byteSize: m.b, + ...(typeof m.l === 'string' ? { lane: m.l } : {}), + }; } catch { // fall through to the derived default below } @@ -83,9 +98,9 @@ export function createFileChunkBackend( return { generation, - openPart(ref: PartRef, start: number): void { + openPart(ref: PartRef, start: number, lane?: string): void { openStarts.set(startKey(ref), start); - writeMeta(ref.generation, ref.number, start, null, 0); + writeMeta(ref.generation, ref.number, start, null, 0, lane); }, appendEntry(ref: PartRef, record: StoredEntry): number { @@ -98,9 +113,10 @@ export function createFileChunkBackend( }, closePart(ref: PartRef, end: number, byteSize: number): void { - const start = openStarts.get(startKey(ref)) ?? readMeta(ref.generation, ref.number).start; + const known = readMeta(ref.generation, ref.number); + const start = openStarts.get(startKey(ref)) ?? known.start; openStarts.delete(startKey(ref)); - writeMeta(ref.generation, ref.number, start, end, byteSize); + writeMeta(ref.generation, ref.number, start, end, byteSize, known.lane); // A sealed chunk: let a batched storage flush + close its file handles (no-op otherwise), keeping the // open-handle count bounded to the active chunk rather than the whole rolling window. storage.sealChunk?.(ref.generation, ref.number); diff --git a/packages/core/src/memory-chunk-backend.test.ts b/packages/core/src/memory-chunk-backend.test.ts index 71d7e117..540e1f45 100644 --- a/packages/core/src/memory-chunk-backend.test.ts +++ b/packages/core/src/memory-chunk-backend.test.ts @@ -104,3 +104,16 @@ describe('createMemoryChunkBackend', () => { expect(b.listParts(0)).toEqual([]); }); }); + +describe('createMemoryChunkBackend — part lane in the durable meta', () => { + it('openPart(lane) is listed with its lane, open and after closePart; no lane lists none', async () => { + const b = createMemoryChunkBackend({ generation: 5 }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.openPart(ref(5, 1), 1); + expect((await b.listParts(5))[0]?.lane).toBe('win-3'); + b.closePart(ref(5, 0), 2, 7); + const parts = await b.listParts(5); + expect(parts[0]).toMatchObject({ number: 0, end: 2, byteSize: 7, lane: 'win-3' }); + expect(parts[1]).not.toHaveProperty('lane'); + }); +}); diff --git a/packages/core/src/memory-chunk-backend.ts b/packages/core/src/memory-chunk-backend.ts index e8016018..226e3f81 100644 --- a/packages/core/src/memory-chunk-backend.ts +++ b/packages/core/src/memory-chunk-backend.ts @@ -24,7 +24,7 @@ export function createMemoryChunkBackend(options?: MemoryChunkBackendOptions): C return { generation, - openPart(ref: PartRef, start: number): void { + openPart(ref: PartRef, start: number, lane?: string): void { data.set(key(ref), []); meta.set(key(ref), { generation: ref.generation, @@ -32,6 +32,7 @@ export function createMemoryChunkBackend(options?: MemoryChunkBackendOptions): C start, end: undefined, byteSize: 0, + ...(lane !== undefined ? { lane } : {}), }); }, diff --git a/packages/core/src/native-crash-recovery.test.ts b/packages/core/src/native-crash-recovery.test.ts index afecd6b8..985656ac 100644 --- a/packages/core/src/native-crash-recovery.test.ts +++ b/packages/core/src/native-crash-recovery.test.ts @@ -441,3 +441,24 @@ describe('recoverNativeCrashes', () => { expect(source.claims).toEqual(['main.dmp']); }); }); + +describe('recoverNativeCrashes — windows of the crashed session', () => { + it('freezes each part with the lane the crashed run persisted for it', async () => { + const fake = fakeBackend(); + const lanes: PartMeta[] = [ + { generation: 0, number: 0, start: 0, end: 1, byteSize: 1, lane: 'w1' }, + { generation: 0, number: 1, start: 0, end: 1, byteSize: 1 }, + ]; + const backend = { ...fake.backend, listParts: (): PartMeta[] => lanes }; + const { pipeline } = fakePipeline(); + await recoverNativeCrashes({ + backend, + marker: marker(), + source: fakeSource([dump('main.dmp', 1)]), + context: baseContext, + uploadPipeline: pipeline, + generateId: () => 'nc-1', + }); + expect((fake.snapshotCalls[0] ?? []).map((part) => part.lane)).toEqual(['w1', undefined]); + }); +}); diff --git a/packages/core/src/native-crash-recovery.ts b/packages/core/src/native-crash-recovery.ts index f8f83fe2..8bf7ec43 100644 --- a/packages/core/src/native-crash-recovery.ts +++ b/packages/core/src/native-crash-recovery.ts @@ -110,6 +110,7 @@ export async function recoverNativeCrashes( const frozen: FrozenPart[] = parts.map((part) => ({ ref: { generation: marker.generation, number: part.number }, count: Number.MAX_SAFE_INTEGER, // recover the whole part + ...(part.lane !== undefined ? { lane: part.lane } : {}), })); for (const dump of dumps) { From ceff29dff6e26cb7a7f53aa33306cda6971649ea Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 13:59:46 +0500 Subject: [PATCH 21/33] feat(core,electron): a closed window's replay lane is retired once its data leaves the recording window (per-window replay L6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CaptureStore.retireLane marks a lane; the chunk store drops it when none of its data is inside the window, and a new record revives it. The Electron main retires the lane of a renderer that was destroyed or whose process is gone. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/per-window-replay.md | 9 ++-- packages/core/src/chunk-capture-store.test.ts | 50 +++++++++++++++++++ packages/core/src/chunk-capture-store.ts | 18 +++++++ packages/core/src/contracts.ts | 6 +++ packages/electron/src/launch-main.test.ts | 24 ++++++++- packages/electron/src/launch-main.ts | 2 + packages/electron/src/main-control.test.ts | 49 ++++++++++++++++++ packages/electron/src/main-control.ts | 12 +++++ 9 files changed, 166 insertions(+), 6 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 6affd71a..9795598b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); the ~20 MB assembler residual of the browser export is unexplained. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); closed-window lanes are retired (L6); the ~20 MB assembler residual of the browser export is unexplained. diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index 41aa732d..16d005b8 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -42,11 +42,12 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of * **L5 DONE 2026-11-01** the lane is persisted in the part meta (all backends and the disk-pressure wrapper), and capture recovery and native-crash recovery freeze each part with it: a crashed multi-window session rebuilds one replay per window. +* **L6 DONE 2026-11-01** `CaptureStore.retireLane(lane)`: main retires a window's lane when its webContents is destroyed or its + process is gone; the store drops it once none of its data is inside the recording window (so the report range is kept + whole), and any new record for the lane revives it (a crashed window that reloads keeps its id). + Known limits of what shipped: the viewer still reads only `replay.bin`, which is the first window to produce a base, not -necessarily the key window. A window that closes leaves its last base segment in the store until the byte cap sheds it (it is -the oldest visual data, so it goes first): time eviction keeps the newest base per lane, and an idle window is -indistinguishable from a closed one without a signal from main, so the lane is not dropped on `rendererGone` yet (open: -a store `dropLane`). A stream-encoded lane keeps the type's own manifest attrs (a laned video keeps `format`). +necessarily the key window. A stream-encoded lane keeps the type's own manifest attrs (a laned video keeps `format`). Viewer (checked 2026-11-01, `viewer/src/app/features/recording/shared/recording-session.service.ts`): it takes `files.find(type === 'replay')`, i.e. only the FIRST replay entry of the manifest, so extra windows are ignored today and diff --git a/packages/core/src/chunk-capture-store.test.ts b/packages/core/src/chunk-capture-store.test.ts index 886634a7..83e7315f 100644 --- a/packages/core/src/chunk-capture-store.test.ts +++ b/packages/core/src/chunk-capture-store.test.ts @@ -600,4 +600,54 @@ describe('createChunkCaptureStore — one visual lane per source key (per-window // [0] is the store's own first data part; then a's part, then the unkeyed visual part expect(opened).toEqual([[], ['a'], []]); }); + + describe('retireLane (a window that closed)', () => { + const mk = () => + createChunkCaptureStore(createMemoryChunkBackend(), { + clock: clockAt(0), + maxRecordingTimeMs: 2000, + }); + const run = (store: ReturnType, from: number, to: number) => { + for (let s = from; s < to; s++) { + store.tick((s + 1) * 1000); + } + }; + + it('drops a retired lane once none of its data is inside the window, and keeps an idle lane that was not retired', async () => { + const store = mk(); + store.add(rec('closed', 'c0', 0, true)); + store.add(rec('idle', 'i0', 0, true)); + run(store, 0, 1); + store.retireLane?.('closed'); + run(store, 1, 10); + expect(await held(store)).toEqual({ idle: ['i0'] }); + }); + + it('keeps a retired lane while its data is still inside the window (the report range is kept whole)', async () => { + const store = mk(); + run(store, 0, 8); + store.add(rec('closed', 'c8', 8, true)); + run(store, 8, 9); + store.retireLane?.('closed'); + run(store, 9, 10); // now = 10 s, window starts at 8 s: c8 is inside + expect(await held(store)).toEqual({ closed: ['c8'] }); + }); + + it('a record for a retired lane revives it (a crashed window that reloads keeps its id)', async () => { + const store = mk(); + store.add(rec('w', 'w0', 0, true)); + run(store, 0, 1); + store.retireLane?.('w'); + store.add(rec('w', 'w1', 1, true)); + run(store, 1, 10); + expect((await held(store)).w).toEqual(['w1']); + }); + + it('retiring an unknown lane is harmless', async () => { + const store = mk(); + store.retireLane?.('nobody'); + store.add(rec('a', 'a0', 0, true)); + expect(await held(store)).toEqual({ a: ['a0'] }); + }); + }); }); diff --git a/packages/core/src/chunk-capture-store.ts b/packages/core/src/chunk-capture-store.ts index 85892a10..1e172680 100644 --- a/packages/core/src/chunk-capture-store.ts +++ b/packages/core/src/chunk-capture-store.ts @@ -113,6 +113,8 @@ export function createChunkCaptureStore( let nextNumber = 0; // The OPEN visual part of each source key ('' = the unkeyed source), opened lazily by its first record of a tick. const openVisual = new Map(); + // Lanes whose window closed: dropped once nothing of theirs is inside the recording window (see `retireLane`). + const retired = new Set(); const openNewPart = (start: number, lane: Lane, key?: string): IndexedPart => { const ref: PartRef = { generation, number: nextNumber }; @@ -148,6 +150,7 @@ export function createChunkCaptureStore( return dataPart; } const name = record.lane ?? ''; + retired.delete(name); let part = openVisual.get(name); if (part === undefined) { part = openNewPart(dataPart.start, 'visual', record.lane); @@ -301,6 +304,16 @@ export function createChunkCaptureStore( const keys = new Set(parts.filter((part) => part.lane === 'visual').map((part) => part.key)); for (const key of keys) { evictByTime('visual', nowMs, key); + if (key !== undefined && retired.has(key)) { + const rest = parts.filter((part) => part.lane === 'visual' && part.key === key); + // A retired lane has no open part (the tick above closed it), so the newest end says when it last held data. + if (rest.every((part) => (part.end ?? nowMs) < nowMs - maxRecordingTimeMs)) { + for (const part of rest) { + evictPart(part); + } + retired.delete(key); + } + } } }, @@ -317,8 +330,13 @@ export function createChunkCaptureStore( return withTimeRange(backend.snapshot(frozen), range); }, + retireLane(lane: string): void { + retired.add(lane); + }, + clear(): void { backend.removeGeneration(generation); + retired.clear(); parts = []; totalBytes = 0; openVisual.clear(); diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index 17d99d29..35bd1f52 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -122,6 +122,12 @@ export interface CaptureStore { * capture. */ snapshot(options?: { owner?: string }): CaptureSnapshot; + /** + * The visual source `lane` will produce nothing more (its window closed). Optional: only a store with visual lanes has + * it. The lane is NOT dropped at once (what it captured inside the recording window is part of the report); it goes once + * none of its data is inside the window. A record for the lane afterwards revives it. + */ + retireLane?(lane: string): void; /** Discard all live records. */ clear(): void; /** diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index 6f411ca0..183f4ce7 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -67,7 +67,8 @@ function fakeLaunch(options: { internals?: boolean } = {}) { const added: unknown[] = []; const stop = vi.fn(() => Promise.resolve(true)); const flush = vi.fn(() => Promise.resolve(true)); - const store = { add: (e: unknown) => added.push(e) }; + const retireLane = vi.fn(); + const store = { add: (e: unknown) => added.push(e), retireLane }; const blackout = createBlackoutState(); const client = { getService: vi.fn((token: unknown) => { @@ -97,6 +98,7 @@ function fakeLaunch(options: { internals?: boolean } = {}) { stop, flush, added, + retireLane, get received() { return received; }, @@ -157,6 +159,26 @@ describe('launchMain', () => { expect(f.stop).toHaveBeenCalledTimes(1); // original node stop still runs }); + describe('a window that is gone retires its replay lane in the main store', () => { + it('retires the lane named by the window id, when its renderer process is gone', () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + const handlers = new Map void>(); + const app = { + on: (event: string, listener: (...args: never[]) => void) => handlers.set(event, listener), + }; + launchMain('tok', { ipcMain: ipc.ipcMain, launch: f.launch, app } as never); + const r = fakeSender(7); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r }, encodeHello()); + handlers.get('render-process-gone')?.( + {} as never, + { id: 7 } as never, + { reason: 'crashed' } as never, + ); + expect(f.retireLane).toHaveBeenCalledWith('7'); + }); + }); + describe('a visual reset of one window (per-window lanes)', () => { const setup = () => { const ipc = fakeIpcMain(); diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 99af1ce4..86b48e3c 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -643,6 +643,8 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee }; const control = createElectronMainControl({ ipcMain, + // A window that closed produces no more replay: its lane leaves the store once its data is out of the window. + onRendererLeft: (id) => store.retireLane?.(String(id)), sessionId: internals.api.sessionId, ...(keyWindow !== undefined ? { keyWindow } : {}), ...(nodeOptions.onError !== undefined ? { onError: nodeOptions.onError } : {}), diff --git a/packages/electron/src/main-control.test.ts b/packages/electron/src/main-control.test.ts index f9f2dec9..e0974b78 100644 --- a/packages/electron/src/main-control.test.ts +++ b/packages/electron/src/main-control.test.ts @@ -1164,3 +1164,52 @@ describe('checkout(windowId) — one window only', () => { expect(control.hasRenderer(4)).toBe(false); }); }); + +describe('onRendererLeft', () => { + const setup = () => { + const ipc = fakeIpcMain(); + const left: number[] = []; + const control = createElectronMainControl({ + ipcMain: ipc.ipcMain, + sessionId: 's1', + onRendererLeft: (id) => left.push(id), + }); + control.start(); + return { ipc, control, left }; + }; + + it('is told when a renderer is destroyed', () => { + const { ipc, left } = setup(); + const destroyed: Array<() => void> = []; + const r = fakeSender(5); + (r.sender as { once?: (e: string, l: () => void) => void }).once = (_e, l) => destroyed.push(l); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r.sender }, encodeHello()); + destroyed[0]?.(); + expect(left).toEqual([5]); + }); + + it('is told when a renderer process is gone', () => { + const { ipc, control, left } = setup(); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: fakeSender(6).sender }, encodeHello()); + control.rendererGone(6); + expect(left).toEqual([6]); + }); + + it('is not told for a sender without an id, and a throwing callback never breaks the control', () => { + const ipc = fakeIpcMain(); + const control = createElectronMainControl({ + ipcMain: ipc.ipcMain, + sessionId: 's1', + onRendererLeft: () => { + throw new Error('host broke'); + }, + }); + control.start(); + const anonymous = fakeSender(1); + delete (anonymous.sender as { id?: number }).id; + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: anonymous.sender }, encodeHello()); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: fakeSender(2).sender }, encodeHello()); + expect(() => control.rendererGone(2)).not.toThrow(); + expect(control.rendererCount).toBe(1); + }); +}); diff --git a/packages/electron/src/main-control.ts b/packages/electron/src/main-control.ts index cd7cb515..f10fde67 100644 --- a/packages/electron/src/main-control.ts +++ b/packages/electron/src/main-control.ts @@ -42,6 +42,11 @@ export interface IpcMainControlLike { export interface ElectronMainControlOptions { ipcMain: IpcMainControlLike; + /** + * A registered renderer left (its webContents was destroyed or its process is gone), by `webContents` id. A throwing + * callback never breaks the control. + */ + onRendererLeft?: (id: number) => void; /** The owner's session id — sent to each renderer on its `hello` handshake. */ sessionId: string; /** The renderer→main handshake channel (default {@link BUGSEE_HELLO_CHANNEL}). */ @@ -146,6 +151,13 @@ export function createElectronMainControl( /** Drop a renderer that is gone. Idempotent — `destroyed` and a later `isDestroyed()` both land here. */ const forget = (sender: ControlSenderLike): void => { renderers.delete(sender); + if (sender.id !== undefined) { + try { + options.onRendererLeft?.(sender.id); + } catch (error) { + options.onError?.(error); + } + } }; // The key-window flag each renderer was LAST told (reply or `window-key`): a change is sent only to those it changed for. From 33975b5b1fdb025061fc577dc168ba9c3b238936 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 14:00:51 +0500 Subject: [PATCH 22/33] docs(streaming-export): the browser export residual is the window's largest rrweb event, bisected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/streaming-export.md | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9795598b..39b1e891 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); closed-window lanes are retired (L6); the ~20 MB assembler residual of the browser export is unexplained. +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); closed-window lanes are retired (L6); the ~20 MB assembler residual of the browser export is the window's largest rrweb event being reified (explained, design doc section 9). diff --git a/docs/design/streaming-export.md b/docs/design/streaming-export.md index 9914a4d4..9752311f 100644 --- a/docs/design/streaming-export.md +++ b/docs/design/streaming-export.md @@ -304,10 +304,13 @@ parts; live heap above the idle store, forced GC every 200 records, sampled insi synchronous):** buffered export +77 MB; streaming export with the original read +33 MB; with the part-by-part read +21 MB. The original `snapshot().stream()` read EVERY part into one array before yielding the first record, so the window sat in the heap decoded; it now reads and decodes one part at a time (`readParts` in `idb-chunk-backend.ts`), and the store side alone costs -+1.7 MB (iterating the raw stream). The remaining ~20 MB is in the assembler, not the store (the in-memory store measures +31 MB -in the same harness); it has not been bisected, and it is not the +4 MB of the node file-store figure above, which came from a -different harness, so the two numbers are not comparable. A failure in a later part now surfaces after earlier parts were served -(the export's memory-build fallback handles it as before). Harness: `scratchpad/idbbench` (not committed). ++1.7 MB (iterating the raw stream). The remaining ~21 MB was bisected (same harness, one type at a time, streaming): replay +11.9 MB, logs +6.2, network +5.2, an empty +export +0.9 (fixed cost). The peak is the same with the Blob sink as with the memory sink, so it is not the output body: it falls +on the window's largest event, a 4.7 MB rrweb FullSnapshot that the reifier parses into an object graph (about 12 MB) before the +stream encoder writes it back out. That is inherent to reifying one record at a time; a pass-through of the serialized string for +replay would remove it and is not done (the cost is transient, one event, against +77 MB buffered). The node file-store figure +above (+4 MB) came from a different harness, so the two numbers are not comparable. A failure in a later part now surfaces after +earlier parts were served (the export's memory-build fallback handles it as before). Harness: `scratchpad/idbbench` (not committed). Limit of the measurement: the claim "the durable window is not in the heap" holds while IndexedDB accepts writes: records it refuses (quota, `AbortError`) are served from memory, still bounded by the From a5469db359a34cc8592bd60dddbe695a63a9bd02 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 14:06:42 +0500 Subject: [PATCH 23/33] feat(core,electron): pixel video per display: a member per lane for encoder types, a display compositor source and Electron adapters (per-window replay L4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assembler splits every encoder type by lane, so the pixel video encoder is no longer handed two displays to concatenate; a video lane that is a display id gets attrs.displayId (what the viewer reads). createDisplayVideoSource composes the windows of each display into one frame per tick and yields one video per display; electronDisplays/electronWindowLayers adapt screen and BrowserWindow. The host injects the compositor. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 2 +- docs/design/per-window-replay.md | 12 +- .../core/src/bundle-stream-assembler.test.ts | 55 ++++- packages/core/src/bundle-stream-assembler.ts | 36 +++- .../src/display-video.integration.test.ts | 92 +++++++++ packages/electron/src/display-video.test.ts | 115 +++++++++++ packages/electron/src/display-video.ts | 79 ++++++++ packages/electron/src/launch-main.ts | 3 +- packages/electron/src/main.test.ts | 3 + packages/electron/src/main.ts | 12 ++ packages/electron/src/video-capture.test.ts | 191 ++++++++++++++++++ packages/electron/src/video-capture.ts | 146 +++++++++++++ 12 files changed, 734 insertions(+), 12 deletions(-) create mode 100644 packages/electron/src/display-video.integration.test.ts create mode 100644 packages/electron/src/display-video.test.ts create mode 100644 packages/electron/src/display-video.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 39b1e891..481a2f9c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1455,4 +1455,4 @@ Verified against current majors, all green with no adapter code changes: `@bugse **Known limits (by design, for the export step):** the anchor is per PART, so up to ~1 s of records before a base inside the same part can precede it; after a lone-base drop the open part keeps incrementals until a fresh base arrives. Export must trim records that precede the first base (per record, not per part). The flag is held in the in-memory part index only; a store rebuilt from disk after a crash is not re-time-evicted, so it is not needed there. **Electron (closed 2026-11-01):** main's store is the one with the byte cap, and its `onVisualReset` now tells every renderer to restart its replay recorder through a new control command `checkout` (`@bugsee/node` `onVisualReset` option -> `launchMain` -> `createElectronMainControl().checkout()` -> `onCheckout` in the renderer -> the browser launch's `onReplayRestart` hook). The pacing (5 s, doubling to 5 min, reset after 10 min of quiet) is the shared `createVisualResetGate` from `@bugsee/core`, also used by the browser launch. **Still open:** renderers of several windows share one visual lane in main without a window id on the record, so time eviction keeps only the newest base across windows (per-window lanes are the follow-up). **Verified:** mutator loop on the lane logic and the new wiring (all mutants killed, hangs caught by timeout); core 1908, replay 228, browser 1339, browser-utils 230, electron 548 tests; coverage gate holds; typecheck, lint, cycles clean; two convergent review rounds. -**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** L4 pixel video per display (compositor, viewer support for secondary members); closed-window lanes are retired (L6); the ~20 MB assembler residual of the browser export is the window's largest rrweb event being reified (explained, design doc section 9). +**Closed 2026-11-01:** the streaming export (`docs/design/streaming-export.md`, slices S0-S6) shipped: `Bundle.body` is a `BundleBody`; reports stream from the store snapshot through `assembleBundleStream` (per-type incremental encoders, fflate streaming zip) into a sink (memory by default, node temp file under `/export/`, Blob available); window trim + replay clamp/orphan-trim apply to every live report; durable staging streams file bodies (`BundleStore.putFrame`); transports pipe/Blob a `BundleBody`; an export failure falls back to a memory build so the report still ships. Measured live-heap cost of the export: +36 MB -> +4 MB on a 20 MB window (section 9). Default `maxDataSize` raised: browser 20 MB (memory) / 30 MB (IndexedDB); Node 50 MB, edge/worker 10 MB unchanged. **Recovery paths (closed later the same day):** capture-recovery and native-crash-recovery stream too (`recovered-bundle.ts`). **Viewer unzip verified 2026-11-01:** the viewer's own `jszip.min.js` (`JSZip.loadAsync`, what its import dialog uses) opens the streamed bundles (memory and file sink), same members and sizes as the buffered bundle apart from the window-trimmed replay; the import dialog reads only `request.json`, so member order is immaterial. **IndexedDB read path measured and fixed 2026-11-01:** its snapshot read the whole window before yielding; it now streams part by part (export heap +33 -> +21 MB on a 20 MB window; buffered +77 MB; design doc section 9). **Per-window replay (owner decision 2026-11-01: pixels compose per display, reconstruction captures per window; `docs/design/per-window-replay.md`):** slices L0-L3 done (lane on records, per-lane store state and reset, backends attach it, one replay member per window, Electron per-window tagging and `checkout`). **Still open:** viewer support for several replay windows (it reads only the first `replay`), a concrete `compose` for the display video; closed-window lanes are retired (L6); pixel video per display (L4: assembler splits by lane with `attrs.displayId`, `createDisplayVideoSource`, Electron screen/window adapters; the host injects `compose`); the ~20 MB assembler residual of the browser export is the window's largest rrweb event being reified (explained, design doc section 9). diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index 16d005b8..a415ff05 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -37,7 +37,14 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of * **L3 DONE 2026-11-01** Electron: the receiver tags a renderer's replay with its `webContents` id; `onVisualReset(lane)` paces and restarts ONLY that window (`checkout(windowId)`, a gate per window; a window with no registered renderer spends no budget); an unkeyed reset still asks everyone. -* **L4 open** pixel video per display (compositor in main), and viewer support for secondary members. +* **L4 DONE 2026-11-01** pixel video per display. The assembler splits every encoder type by lane (so `encodePixelVideo` is no + longer handed two displays to concatenate); a video lane that is a display id gets `attrs.displayId` (what the viewer + already reads), anything else `attrs.window`. `createDisplayVideoSource` (electron) composes the windows of each display into + one frame per tick (`displays`, `layers`, `compose`, `encode` injected), keeps a ring per display, skips a display with no + visible window, forgets a vanished display after the ring's span, and yields one `video` entry per display with the display + id as lane, lowest first. `electronDisplays` / `electronWindowLayers` adapt Electron's `screen` and `BrowserWindow`: display 0 + is the focused window's display (as in the `displays` trace); windows are ordered by creation with the focused one on top, + because Electron exposes no z-order (the adapter's one stated limit). * **L5 DONE 2026-11-01** the lane is persisted in the part meta (all backends and the disk-pressure wrapper), and capture recovery and native-crash recovery freeze each part with it: a crashed multi-window session rebuilds one replay per window. @@ -46,6 +53,9 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of process is gone; the store drops it once none of its data is inside the recording window (so the report range is kept whole), and any new record for the lane revives it (a crashed window that reloads keeps its id). +Not shipped: a concrete `compose` (painting the grabbed windows onto one display-sized frame needs an image library or an +offscreen canvas; the package stays free of both, so the host injects it) and the viewer changes below. + Known limits of what shipped: the viewer still reads only `replay.bin`, which is the first window to produce a base, not necessarily the key window. A stream-encoded lane keeps the type's own manifest attrs (a laned video keeps `format`). diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 1e99075d..0370bfaa 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -1191,7 +1191,7 @@ describe('assembleBundleStream — one stream-encoded member per visual source l it("keeps the type's own manifest attrs next to the window (a laned video keeps its format)", async () => { const encoder = { begin: () => ({ write: () => [strToU8('v')], end: () => [] }) }; - const video = Object.assign(new CaptureDataEntryBase('video', NOW3 - 100, 'f'), { lane: 'd1' }); + const video = Object.assign(new CaptureDataEntryBase('video', NOW3 - 100, 'f'), { lane: '1' }); const bundle = await assembleBundleStream( createReportingRequest({ source: { type: 'error' }, id: 'r1' }), from([video]), @@ -1203,7 +1203,58 @@ describe('assembleBundleStream — one stream-encoded member per visual source l }; expect(manifest.files.find((f) => f.type === 'video')?.attrs).toEqual({ format: 'webm', - window: 'd1', + displayId: 1, + }); + }); +}); + +describe('assembleBundleStream — one member per display for a buffered-encoder type (pixel video)', () => { + const NOW4 = 1_700_000_000_000; + const video = (lane: string | undefined, bytes: number[]): CaptureDataEntry => { + const e = new CaptureDataEntryBase('video', NOW4 - 100, new Uint8Array(bytes)); + if (lane !== undefined) e.lane = lane; + return e; + }; + // The real pixel-video encoder concatenates every payload: a bundle with two displays must NOT reach it as one list. + const encodeVideo = (payloads: unknown[]): Uint8Array => + Uint8Array.from((payloads as Uint8Array[]).flatMap((p) => [...p])); + const run = async (entries: CaptureDataEntry[]) => { + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + from(entries), + context({ fileEncoders: { video: encodeVideo } }), + ); + const files = members(await bundle.body.bytes()); + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + files: Array<{ filename: string; type: string; attrs?: Record }>; + }; + return { files, manifest }; + }; + + it('writes each display its own video member, encoded on its own, with attrs.displayId', async () => { + const { files, manifest } = await run([video('0', [1, 2]), video('1', [3, 4])]); + expect([...(files['video.webm'] as Uint8Array)]).toEqual([1, 2]); + expect([...(files['video.1.webm'] as Uint8Array)]).toEqual([3, 4]); + expect(manifest.files.filter((f) => f.type === 'video')).toEqual([ + expect.objectContaining({ filename: 'video.webm', attrs: { format: 'webm', displayId: 0 } }), + expect.objectContaining({ + filename: 'video.1.webm', + attrs: { format: 'webm', displayId: 1 }, + }), + ]); + }); + + it('an unkeyed video stays the one canonical member with no displayId', async () => { + const { files, manifest } = await run([video(undefined, [9])]); + expect([...(files['video.webm'] as Uint8Array)]).toEqual([9]); + expect(manifest.files.find((f) => f.type === 'video')?.attrs).toEqual({ format: 'webm' }); + }); + + it('a lane that is not a number is recorded as a window, not a display', async () => { + const { manifest } = await run([video('win-a', [1])]); + expect(manifest.files.find((f) => f.type === 'video')?.attrs).toEqual({ + format: 'webm', + window: 'win-a', }); }); }); diff --git a/packages/core/src/bundle-stream-assembler.ts b/packages/core/src/bundle-stream-assembler.ts index 4e647506..521a7b16 100644 --- a/packages/core/src/bundle-stream-assembler.ts +++ b/packages/core/src/bundle-stream-assembler.ts @@ -46,6 +46,17 @@ interface EncodedMember { name: string | undefined; } +/** + * The manifest attrs that name a member's visual source. A pixel `video` is per DISPLAY: the viewer reads `attrs.displayId` + * (a number; absent = 0). Everything else (a window's replay) is `attrs.window`, and so is a video whose lane is not a number. + */ +const laneAttrs = (type: FileType, lane: string): Record => { + const id = Number(lane); + return type === 'video' && lane !== '' && Number.isInteger(id) && id >= 0 + ? { displayId: id } + : { window: lane }; +}; + /** `replay.bin` for the first member of a type, `replay.1.bin`, `replay.2.bin`, ... for the next ones. */ const nthFileName = (type: FileType, index: number): string => { const base = fileNameForType(type); @@ -215,6 +226,7 @@ async function buildInto( const order: string[] = []; // member keys: a type, or `type\0lane` for one visual source of a stream-encoded type const streamed = new Map(); const collected = new Map(); // types with an injected encoder + const collectedOf = new Map(); // what each collected key holds const encoded = new Map(); // types with a stream encoder: one member per visual source lane const filesOfType = new Map(); // members actually created per type (names the next one) const writeChunks = (member: EncodedMember, chunks: Uint8Array[]): void => { @@ -244,9 +256,10 @@ async function buildInto( return; // before the window; types with an encoder (the replay) are clamped by it instead } const streamEncoder = context.streamEncoders?.[type]; - // Only a stream-encoded (reconstructed) type splits by source: every other type is one member whatever its lane. - const key = - streamEncoder !== undefined && entry.lane !== undefined ? `${type}\0${entry.lane}` : type; + // Only a type with an encoder (the reconstructed replay, the pixel video) splits by source, one member per lane: every + // other type is one member whatever its lane. + const splits = streamEncoder !== undefined || context.fileEncoders?.[type] !== undefined; + const key = splits && entry.lane !== undefined ? `${type}\0${entry.lane}` : type; if (!streamed.has(key) && !collected.has(key) && !encoded.has(key)) { order.push(key); if (streamEncoder !== undefined) { @@ -259,6 +272,7 @@ async function buildInto( }); } else if (context.fileEncoders?.[type] !== undefined) { collected.set(key, []); + collectedOf.set(key, { type, lane: entry.lane }); } else { const file = new ZipDeflate(claim(fileNameForType(type))); zip.add(file); @@ -314,7 +328,9 @@ async function buildInto( const dataNames: string[] = []; for (const key of order) { const stream = encoded.get(key); - const type = (stream?.type ?? key) as FileType; + const buffered = collectedOf.get(key); + const type = (stream?.type ?? buffered?.type ?? key) as FileType; + const lane = stream?.lane ?? buffered?.lane; if (stream !== undefined) { writeChunks(stream, stream.session.end()); if (stream.file === undefined) { @@ -322,12 +338,18 @@ async function buildInto( } stream.file.push(new Uint8Array(0), true); } - const name = stream?.name ?? fileNameForType(type); + let name = stream?.name; + if (name === undefined && buffered !== undefined) { + const index = filesOfType.get(type) ?? 0; + filesOfType.set(type, index + 1); + name = nthFileName(type, index); + } + name ??= fileNameForType(type); const entry = manifestEntryForType(type); files.push({ ...entry, filename: name, - ...(stream?.lane !== undefined ? { attrs: { ...entry.attrs, window: stream.lane } } : {}), + ...(lane !== undefined ? { attrs: { ...entry.attrs, ...laneAttrs(type, lane) } } : {}), }); dataNames.push(name); const member = streamed.get(key); @@ -339,7 +361,7 @@ async function buildInto( const encode = (context.fileEncoders as NonNullable)[type] as ( payloads: unknown[], ) => Uint8Array; - addWhole(fileNameForType(type), encode(collected.get(key) as unknown[])); + addWhole(name, encode(collected.get(key) as unknown[])); } } diff --git a/packages/electron/src/display-video.integration.test.ts b/packages/electron/src/display-video.integration.test.ts new file mode 100644 index 00000000..df893f07 --- /dev/null +++ b/packages/electron/src/display-video.integration.test.ts @@ -0,0 +1,92 @@ +import { + assembleBundleStream, + type BundleAssemblyContext, + type CaptureDataEntry, + createReportingRequest, +} from '@bugsee/core'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { describe, expect, it } from 'vitest'; +import { electronDisplays, electronWindowLayers, type WindowLike } from './display-video'; +import { createPixelVideoController, encodePixelVideo } from './pixel-video-controller'; +import { createDisplayVideoSource } from './video-capture'; + +// Display source -> pixel controller -> bundle assembler, with the REAL `encodePixelVideo` (which concatenates every payload it is +// given): two displays must reach it as two separate files, not as one concatenated one. +const rect = (x: number, y: number, width: number, height: number) => ({ x, y, width, height }); +const win = (id: number, bounds: ReturnType, focused = false): WindowLike => ({ + id, + getBounds: () => bounds, + isVisible: () => true, + isMinimized: () => false, + isFocused: () => focused, + webContents: { capturePage: async () => ({ toJPEG: () => new Uint8Array([id]) }) }, +}); + +describe('a two-display session, end to end', () => { + it('ships one video member per display, each holding only its own display frames, named by attrs.displayId', async () => { + const windows = [win(1, rect(10, 10, 100, 100), true), win(2, rect(1100, 10, 100, 100))]; + const BrowserWindow = { getAllWindows: () => windows }; + const screen = { + getAllDisplays: () => [ + { id: 30, bounds: rect(0, 0, 1000, 600) }, + { id: 70, bounds: rect(1000, 0, 800, 600) }, + ], + }; + let tick: (() => unknown) | undefined; + const controller = createPixelVideoController({ + source: createDisplayVideoSource({ + displays: electronDisplays(screen, BrowserWindow), + layers: electronWindowLayers(BrowserWindow), + // "compose": the first layer's bytes stand for the composed frame of the display + compose: async (display, layers) => + new Uint8Array([display.id, ...layers.map((l) => l.bytes[0] as number)]), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: { + setInterval: (cb) => { + tick = cb; + return 'h'; + }, + clearInterval: () => {}, + }, + now: () => 1_700_000_000_000, + }), + }); + await controller.start(); + await tick?.(); + const extra = new Map([ + ['video', await controller.snapshot(1_700_000_000_000)], + ]); + + async function* none(): AsyncGenerator {} + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + none(), + { + appToken: 'tok', + environment: { + platform: { type: 'electron', version: '1' }, + runtime: { type: 'electron', version: '' }, + sdk: { version: '1', type: 'javascript' }, + } as never, + attributes: {}, + clock: { wallNow: () => 1_700_000_000_000, monotonicNow: () => 0 }, + fileName: () => 'b.zip', + fileEncoders: { video: encodePixelVideo }, + } satisfies BundleAssemblyContext, + extra as never, + ); + const files = unzipSync(await bundle.body.bytes()); + // display 0 = the focused window's display (native 30, window 1); display 1 = native 70 (window 2) + expect([...(files['video.webm'] as Uint8Array)]).toEqual([0, 1]); + expect([...(files['video.1.webm'] as Uint8Array)]).toEqual([1, 2]); + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + files: Array<{ filename: string; type: string; attrs?: Record }>; + }; + expect( + manifest.files.filter((f) => f.type === 'video').map((f) => [f.filename, f.attrs]), + ).toEqual([ + ['video.webm', { format: 'webm', displayId: 0 }], + ['video.1.webm', { format: 'webm', displayId: 1 }], + ]); + }); +}); diff --git a/packages/electron/src/display-video.test.ts b/packages/electron/src/display-video.test.ts new file mode 100644 index 00000000..af5bf929 --- /dev/null +++ b/packages/electron/src/display-video.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { electronDisplays, electronWindowLayers, type WindowLike } from './display-video'; + +const rect = (x: number, y: number, width: number, height: number) => ({ x, y, width, height }); +const win = ( + id: number, + bounds: ReturnType, + over: Partial = {}, +): WindowLike => ({ + id, + getBounds: () => bounds, + isVisible: () => true, + isMinimized: () => false, + isFocused: () => false, + webContents: { capturePage: async () => ({ toJPEG: (q: number) => new Uint8Array([id, q]) }) }, + ...over, +}); +const screenOf = (...displays: Array<{ id: number; bounds: ReturnType }>) => ({ + getAllDisplays: () => displays, +}); +const windowsOf = (...windows: WindowLike[]) => ({ getAllWindows: () => windows }); + +describe('electronDisplays', () => { + const screen = screenOf( + { id: 70, bounds: rect(1000, 0, 800, 600) }, + { id: 30, bounds: rect(0, 0, 1000, 600) }, + ); + + it('numbers the display of the focused window 0 and the others by ascending native id', () => { + const displays = electronDisplays( + screen, + windowsOf(win(1, rect(1100, 10, 100, 100), { isFocused: () => true })), + ); + expect(displays().map((d) => [d.id, d.bounds.x])).toEqual([ + [0, 1000], + [1, 0], + ]); + }); + + it('without a focused window the lowest native id is display 0', () => { + const displays = electronDisplays(screen, windowsOf(win(1, rect(1100, 10, 100, 100)))); + expect(displays().map((d) => [d.id, d.bounds.x])).toEqual([ + [0, 0], + [1, 1000], + ]); + }); + + it('ignores a destroyed focused window, and falls back to the first display when the focused window is on none', () => { + const destroyed = win(1, rect(1100, 10, 1, 1), { + isFocused: () => true, + isDestroyed: () => true, + }); + expect(electronDisplays(screen, windowsOf(destroyed))()[0]?.bounds.x).toBe(0); + const off = win(2, rect(9000, 9000, 10, 10), { isFocused: () => true }); + expect(electronDisplays(screen, windowsOf(off))()[0]?.bounds.x).toBe(0); + }); +}); + +describe('electronWindowLayers', () => { + const display = { id: 0, bounds: rect(0, 0, 1000, 600) }; + + it('lists visible overlapping windows back to front: creation order, the focused one on top', async () => { + const layers = electronWindowLayers( + windowsOf( + win(3, rect(0, 0, 10, 10)), + win(1, rect(0, 0, 10, 10), { isFocused: () => true }), + win(2, rect(0, 0, 10, 10)), + ), + )(display); + const grabbed = await Promise.all(layers.map((layer) => layer.capture())); + expect(grabbed.map((bytes) => bytes[0])).toEqual([2, 3, 1]); + }); + + it('skips hidden, minimized, destroyed and off-display windows', () => { + const layers = electronWindowLayers( + windowsOf( + win(1, rect(0, 0, 10, 10), { isVisible: () => false }), + win(2, rect(0, 0, 10, 10), { isMinimized: () => true }), + win(3, rect(0, 0, 10, 10), { isDestroyed: () => true }), + win(4, rect(2000, 0, 10, 10)), + win(5, rect(990, 590, 100, 100)), + ), + )(display); + expect(layers).toHaveLength(1); + expect(layers[0]?.bounds).toEqual(rect(990, 590, 100, 100)); + }); + + it('overlap is checked on both axes and a window that only touches the display edge is not on it', () => { + const layers = electronWindowLayers( + windowsOf( + win(1, rect(10, 700, 10, 10)), // same columns, below the display + win(6, rect(10, 600, 10, 10)), // starts exactly at the bottom edge + win(2, rect(1000, 0, 100, 100)), // starts exactly at the right edge + win(3, rect(-50, 0, 50, 100)), // ends exactly at the left edge + win(4, rect(10, -10, 10, 10)), // ends exactly at the top edge + win(5, rect(10, 590, 10, 10)), // inside + ), + )(display); + expect(layers.map((l) => l.bounds.y)).toEqual([590]); + }); + + it('grabs JPEG at the given quality (60 by default)', async () => { + const w = win(1, rect(0, 0, 10, 10)); + const spy = vi.spyOn(w.webContents, 'capturePage'); + expect([ + ...(await (electronWindowLayers(windowsOf(w))(display)[0]?.capture() ?? + Promise.resolve(new Uint8Array()))), + ]).toEqual([1, 60]); + expect([ + ...(await (electronWindowLayers(windowsOf(w), 90)(display)[0]?.capture() ?? + Promise.resolve(new Uint8Array()))), + ]).toEqual([1, 90]); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/electron/src/display-video.ts b/packages/electron/src/display-video.ts new file mode 100644 index 00000000..d213b0be --- /dev/null +++ b/packages/electron/src/display-video.ts @@ -0,0 +1,79 @@ +// Electron adapters for the per-display pixel video: which displays exist, and which windows are on each one. Both take the +// Electron objects as structural arguments (no electron dependency), so they are testable with fakes. The compositor and +// the muxer stay injected (`createDisplayVideoSource`). +import type { DisplayInfo, Rect, WindowLayer } from './video-capture'; + +/** The slice of Electron's `screen` used. */ +export interface ScreenLike { + getAllDisplays(): Array<{ id: number; bounds: Rect }>; +} + +/** The slice of an Electron `BrowserWindow` used. */ +export interface WindowLike { + id: number; + getBounds(): Rect; + isVisible(): boolean; + isMinimized(): boolean; + isFocused(): boolean; + isDestroyed?(): boolean; + webContents: { capturePage(): Promise<{ toJPEG(quality: number): Uint8Array }> }; +} + +export interface ElectronBrowserWindowLike { + getAllWindows(): WindowLike[]; +} + +const overlaps = (a: Rect, b: Rect): boolean => + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +/** + * The displays as the `displays` trace numbers them: id 0 is the display of the focused window (the key window), the others + * follow in ascending native id. Without a focused window the lowest native id is 0. + */ +export function electronDisplays( + screen: ScreenLike, + BrowserWindow: ElectronBrowserWindowLike, +): () => DisplayInfo[] { + return () => { + const native = [...screen.getAllDisplays()].sort((a, b) => a.id - b.id); + const focused = BrowserWindow.getAllWindows().find( + (window) => window.isDestroyed?.() !== true && window.isFocused(), + ); + const keyIndex = + focused === undefined + ? 0 + : Math.max( + 0, + native.findIndex((display) => overlaps(display.bounds, focused.getBounds())), + ); + const ordered = [ + ...native.slice(keyIndex, keyIndex + 1), + ...native.filter((_, i) => i !== keyIndex), + ]; + return ordered.map((display, id) => ({ id, bounds: display.bounds })); + }; +} + +/** + * The visible, non-minimized windows that overlap a display, back to front. Electron exposes no z-order, so: windows in + * creation order (ascending id), the focused one last (on top). A heuristic, and the one stated limit of this adapter. + */ +export function electronWindowLayers( + BrowserWindow: ElectronBrowserWindowLike, + quality = 60, +): (display: DisplayInfo) => WindowLayer[] { + return (display) => + BrowserWindow.getAllWindows() + .filter( + (window) => + window.isDestroyed?.() !== true && + window.isVisible() && + !window.isMinimized() && + overlaps(window.getBounds(), display.bounds), + ) + .sort((a, b) => Number(a.isFocused()) - Number(b.isFocused()) || a.id - b.id) + .map((window) => ({ + bounds: window.getBounds(), + capture: async () => (await window.webContents.capturePage()).toJPEG(quality), + })); +} diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 86b48e3c..52c22c8f 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -130,7 +130,8 @@ import type { VideoCaptureSource } from './video-capture'; type NodeLaunch = typeof launchCore; /** Opt-in pixel-capture video. The app supplies a source built with its Electron/DOM specifics (e.g. - * `createCapturePageVideoSource` over `webContents.capturePage`, and/or a `MediaRecorder` source — they can + * `createCapturePageVideoSource` over `webContents.capturePage`, `createDisplayVideoSource` (one composed video per display), + * and/or a `MediaRecorder` source — they can * be composed into one source). rrweb DOM-replay stays the DEFAULT and rides the renderer streaming path. */ export interface VideoLaunchOptions { /** The pixel-video capture source. */ diff --git a/packages/electron/src/main.test.ts b/packages/electron/src/main.test.ts index 6bf54e50..324e8516 100644 --- a/packages/electron/src/main.test.ts +++ b/packages/electron/src/main.test.ts @@ -13,6 +13,9 @@ describe('@bugsee/electron/main entry', () => { expect(typeof main.createPixelVideoController).toBe('function'); expect(typeof main.encodePixelVideo).toBe('function'); expect(typeof main.createCapturePageVideoSource).toBe('function'); + expect(typeof main.createDisplayVideoSource).toBe('function'); + expect(typeof main.electronDisplays).toBe('function'); + expect(typeof main.electronWindowLayers).toBe('function'); expect(typeof main.createMediaRecorderVideoSource).toBe('function'); }); }); diff --git a/packages/electron/src/main.ts b/packages/electron/src/main.ts index 29a3ebf1..83804946 100644 --- a/packages/electron/src/main.ts +++ b/packages/electron/src/main.ts @@ -22,6 +22,13 @@ export { type InstallNativeCrashReporterOptions, installNativeCrashReporter, } from './crash-reporter'; +export { + type ElectronBrowserWindowLike, + electronDisplays, + electronWindowLayers, + type ScreenLike, + type WindowLike, +} from './display-video'; export { type LaunchMainOptions, launchMain, type VideoLaunchOptions } from './launch-main'; export { type ControlSenderLike, @@ -57,9 +64,14 @@ export type { DecodedReport } from './protocol'; export { type CapturePageVideoSourceOptions, createCapturePageVideoSource, + createDisplayVideoSource, createMediaRecorderVideoSource, + type DisplayInfo, + type DisplayVideoSourceOptions, type MediaRecorderLike, type MediaRecorderVideoSourceOptions, + type Rect, type VideoCaptureSource, type VideoFrame, + type WindowLayer, } from './video-capture'; diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index f2ed2e41..88ec245c 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -2,6 +2,7 @@ import type { Scheduler } from '@bugsee/core'; import { describe, expect, it, vi } from 'vitest'; import { createCapturePageVideoSource, + createDisplayVideoSource, createMediaRecorderVideoSource, type MediaRecorderLike, } from './video-capture'; @@ -343,3 +344,193 @@ describe('createMediaRecorderVideoSource.clear (blackout: nothing may survive)', expect(entry?.data).toEqual(new Uint8Array([1])); // only the post-restart chunk }); }); + +describe('createDisplayVideoSource — one composed frame per display', () => { + const rect = (x: number, y: number, width: number, height: number) => ({ x, y, width, height }); + const setup = (over: Partial[0]> = {}) => { + const s = fakeScheduler(); + let time = 1000; + const displays = [ + { id: 0, bounds: rect(0, 0, 100, 100) }, + { id: 1, bounds: rect(100, 0, 100, 100) }, + ]; + const layersOf = new Map< + number, + Array<{ bounds: ReturnType; capture: () => Promise }> + >([ + [ + 0, + [ + { bounds: rect(0, 0, 50, 50), capture: async () => new Uint8Array([0xa]) }, + { bounds: rect(10, 10, 50, 50), capture: async () => new Uint8Array([0xb]) }, + ], + ], + [1, [{ bounds: rect(100, 0, 100, 100), capture: async () => new Uint8Array([0xc]) }]], + ]); + const compose = vi.fn( + async (display: { id: number }, layers: ReadonlyArray<{ bytes: Uint8Array }>) => + new Uint8Array([display.id, ...layers.map((l) => l.bytes[0] as number)]), + ); + const encode = vi.fn((frames: readonly { bytes: Uint8Array }[], fps: number) => + Uint8Array.from([fps, ...frames.flatMap((f) => [...f.bytes])]), + ); + const source = createDisplayVideoSource({ + displays: () => displays, + layers: (display) => layersOf.get(display.id) ?? [], + compose, + encode, + scheduler: s.scheduler, + now: () => time, + ...over, + }); + return { s, source, compose, encode, displays, layersOf, at: (t: number) => (time = t) }; + }; + + it('composes the windows of each display back to front into one frame, and yields one video entry per display with its id as lane', async () => { + const { s, source, compose } = setup(); + source.start(); + await s.tick(); + expect(compose).toHaveBeenCalledTimes(2); + expect((compose.mock.calls[0]?.[1] ?? []).map((l) => l.bytes[0])).toEqual([0xa, 0xb]); // z order kept + const entries = await source.snapshot(5); + expect(entries.map((e) => [e.type, e.lane, [...(e.data as Uint8Array)]])).toEqual([ + ['video', '0', [1, 0, 0xa, 0xb]], + ['video', '1', [1, 1, 0xc]], + ]); + }); + + it('orders the entries by display id, whatever order the displays were listed in', async () => { + const { s, source, displays } = setup(); + displays.reverse(); + source.start(); + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0', '1']); + }); + + it('skips a display with no visible window (no frame, no entry)', async () => { + const { s, source, layersOf } = setup(); + layersOf.set(1, []); + source.start(); + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0']); + }); + + it('drops a window whose grab fails, reports it, and still composes the rest', async () => { + const errors: unknown[] = []; + const { s, source, compose, layersOf } = setup({ onError: (e) => errors.push(e) }); + layersOf.set(0, [ + { + bounds: rect(0, 0, 10, 10), + capture: async () => { + throw new Error('grab failed'); + }, + }, + { bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([0xb]) }, + ]); + source.start(); + await s.tick(); + expect((compose.mock.calls[0]?.[1] ?? []).map((l) => l.bytes[0])).toEqual([0xb]); + expect(errors).toHaveLength(1); + }); + + it('a display whose compose fails loses that frame only, and the other display still records', async () => { + const errors: unknown[] = []; + const base = setup(); + const compose = vi.fn(async (display: { id: number }) => { + if (display.id === 0) throw new Error('compose failed'); + return new Uint8Array([9]); + }); + const { s, source } = setup({ compose, onError: (e) => errors.push(e) }); + source.start(); + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['1']); + expect(errors).toHaveLength(1); + void base; + }); + + it('keeps at most maxFrames per display (drop-oldest)', async () => { + const { s, source, at } = setup({ maxFrames: 2 }); + source.start(); + for (const t of [1000, 1001, 1002]) { + at(t); + await s.tick(); + } + const first = (await source.snapshot(5))[0]; + expect((first?.data as Uint8Array).length).toBe(1 + 2 * 3); // fps byte + 2 frames of [id, a, b] + }); + + it('a tick that starts while the previous one is still grabbing is skipped', async () => { + let release: () => void = () => {}; + const slow = new Promise((resolve) => (release = resolve)); + const { s, source, compose, layersOf } = setup(); + layersOf.set(0, [ + { + bounds: rect(0, 0, 1, 1), + capture: async () => { + await slow; + return new Uint8Array([1]); + }, + }, + ]); + source.start(); + const first = s.tick(); + await s.tick(); // overlaps: skipped + release(); + await first; + expect(compose).toHaveBeenCalledTimes(2); // display 0 and display 1 of the FIRST tick only + }); + + it('clear() forgets every frame, including a grab still in flight', async () => { + let release: () => void = () => {}; + const slow = new Promise((resolve) => (release = resolve)); + const { s, source, layersOf } = setup(); + layersOf.set(0, [ + { + bounds: rect(0, 0, 1, 1), + capture: async () => { + await slow; + return new Uint8Array([1]); + }, + }, + ]); + source.start(); + const pending = s.tick(); + source.clear(); + release(); + await pending; + expect(await source.snapshot(5)).toEqual([]); + }); + + it('forgets a display that has been gone for longer than the ring covers', async () => { + const { s, source, displays, at } = setup({ maxFrames: 2 }); + source.start(); + await s.tick(); // t = 1000: both displays have a frame + displays.pop(); // display 1 is unplugged + at(1000 + 2 * 1000 + 1); // ring covers maxFrames / fps = 2 s + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0']); + }); + + it('the ring of a vanished display lasts maxFrames / fps seconds (a faster rate covers less time)', async () => { + const { s, source, displays, at } = setup({ maxFrames: 2, fps: 2 }); // 2 frames at 2 fps = 1 s + source.start(); + await s.tick(); // t = 1000 + displays.pop(); + at(1000 + 1500); + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0']); + }); + + it('start is idempotent, stop clears the timer, and the source samples at the configured fps', () => { + const { s, source } = setup({ fps: 4 }); + source.start(); + source.start(); + expect(s.ms).toBe(250); + source.stop(); + expect(s.cleared).toBe(true); + }); + + it('returns no entries before anything was captured', async () => { + expect(await setup().source.snapshot(5)).toEqual([]); + }); +}); diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index 60582e81..87133bf3 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -163,3 +163,149 @@ export function createMediaRecorderVideoSource( }, }; } + +/** A rectangle in desktop (DIP) coordinates. */ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** One display: its id (`displays` trace order: 0 = the key window's display) and its desktop bounds. */ +export interface DisplayInfo { + id: number; + bounds: Rect; +} + +/** One visible window that overlaps a display: where it is, and a grab of its own pixels. */ +export interface WindowLayer { + bounds: Rect; + capture: () => Promise; +} + +export interface DisplayVideoSourceOptions { + /** The displays present now (real: `screen.getAllDisplays()`). */ + displays: () => readonly DisplayInfo[]; + /** The visible windows that overlap `display`, BACK TO FRONT (real: the app's `BrowserWindow`s by z-order). */ + layers: (display: DisplayInfo) => readonly WindowLayer[]; + /** + * Paint the windows onto one frame the size of the display and encode it (real: an offscreen canvas or an image + * library; an injected seam, like `encode`). Layers arrive back to front with their grabbed bytes and bounds. + */ + compose: ( + display: DisplayInfo, + layers: ReadonlyArray<{ bytes: Uint8Array; bounds: Rect }>, + ) => Promise; + /** Mux one display's frame ring into the container bytes (as for {@link CapturePageVideoSourceOptions}). */ + encode: (frames: readonly VideoFrame[], fps: number) => Uint8Array; + scheduler: Scheduler; + /** Frames per second (default 1). */ + fps?: number; + /** Max frames retained PER DISPLAY (drop-oldest). Default 300. */ + maxFrames?: number; + now: () => number; + /** Sink for a failed grab or compose — the timer never throws. */ + onError?: (error: unknown) => void; +} + +/** + * A MAIN-process pixel-video source that follows the mobile model: the windows of one display are composed into ONE frame, and + * a report carries one `video` per display (entry lane = the display id; the bundle names the member and sets + * `attrs.displayId`). A display with no visible window records nothing for that tick. + */ +export function createDisplayVideoSource(options: DisplayVideoSourceOptions): VideoCaptureSource { + const fps = options.fps ?? 1; + const maxFrames = options.maxFrames ?? 300; + const rings = new Map(); + let handle: unknown; + let epoch = 0; // bumped by clear(): a frame that began before it is discarded + let busy = false; // a slow compose must not overlap the next tick + + const grab = async ( + layers: readonly WindowLayer[], + ): Promise> => { + const grabbed = await Promise.all( + layers.map(async (layer) => { + try { + return { bytes: await layer.capture(), bounds: layer.bounds }; + } catch (error) { + options.onError?.(error); + return undefined; // one window failing does not cost the display its frame + } + }), + ); + return grabbed.filter((layer) => layer !== undefined); + }; + + const frameOf = async (display: DisplayInfo, started: number): Promise => { + try { + const layers = await grab(options.layers(display)); + if (layers.length === 0) { + return; + } + const bytes = await options.compose(display, layers); + if (started !== epoch) { + return; + } + const ring = rings.get(display.id) ?? []; + rings.set(display.id, ring); + ring.push({ timestamp: options.now(), bytes }); + if (ring.length > maxFrames) { + ring.shift(); + } + } catch (error) { + options.onError?.(error); + } + }; + + const tick = async (): Promise => { + if (busy) { + return; + } + busy = true; + const started = epoch; + try { + await Promise.all(options.displays().map((display) => frameOf(display, started))); + // A display that is gone keeps its frames only as long as the ring covers: after that nothing of it is in the window. + const cutoff = options.now() - (maxFrames / fps) * 1000; + for (const [id, ring] of rings) { + if ((ring[ring.length - 1]?.timestamp ?? 0) < cutoff) { + rings.delete(id); + } + } + } finally { + busy = false; + } + }; + + return { + start(): void { + if (handle !== undefined) { + return; + } + // The promise is returned (it never rejects: every failure is caught inside), so a scheduler or a test can await a tick. + handle = options.scheduler.setInterval(() => tick(), Math.round(1000 / fps)); + }, + stop(): void { + if (handle !== undefined) { + options.scheduler.clearInterval(handle); + handle = undefined; + } + }, + clear(): void { + epoch += 1; + rings.clear(); + }, + async snapshot(now: number): Promise { + return [...rings.entries()] + .filter(([, ring]) => ring.length > 0) + .sort(([a], [b]) => a - b) + .map(([id, ring]) => { + const entry = videoEntry(now, options.encode([...ring], fps)); + entry.lane = String(id); + return entry; + }); + }, + }; +} From 920c803fa7f4c1e354de9b3fdcdd584260872f12 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 14:14:11 +0500 Subject: [PATCH 24/33] fix(core,electron): display video keeps one ring per physical display and survives a hung grab; file backend keeps the open lane; late replay from a destroyed window is dropped (review of L4-L6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/per-window-replay.md | 7 +- packages/core/src/file-chunk-backend.test.ts | 24 ++++ packages/core/src/file-chunk-backend.ts | 16 ++- packages/electron/src/display-video.test.ts | 13 ++ packages/electron/src/display-video.ts | 2 +- packages/electron/src/main-control.test.ts | 21 +++ packages/electron/src/main-control.ts | 12 +- packages/electron/src/main-receiver.test.ts | 10 ++ packages/electron/src/main-receiver.ts | 7 +- packages/electron/src/video-capture.test.ts | 139 +++++++++++++++++++ packages/electron/src/video-capture.ts | 93 +++++++++---- 11 files changed, 304 insertions(+), 40 deletions(-) diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index a415ff05..dfbf2971 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -44,7 +44,12 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of visible window, forgets a vanished display after the ring's span, and yields one `video` entry per display with the display id as lane, lowest first. `electronDisplays` / `electronWindowLayers` adapt Electron's `screen` and `BrowserWindow`: display 0 is the focused window's display (as in the `displays` trace); windows are ordered by creation with the focused one on top, - because Electron exposes no z-order (the adapter's one stated limit). + because Electron exposes no z-order (the adapter's one stated limit). Frames are kept per PHYSICAL display (`DisplayInfo.key` + = the native id), because the numbered `id` moves when focus changes monitors; a tick that does not answer for `stuckMs` + (a hung `capturePage`) is abandoned so capture resumes; a failing `encode` costs only its display's video. + Review of L4-L6 also fixed: the file backend keeps the open part's lane itself (no read-back dependency under disk + pressure), a destroyed window's late replay is dropped (it cannot revive a retired lane), `forget` reads `sender.id` inside + its guard. * **L5 DONE 2026-11-01** the lane is persisted in the part meta (all backends and the disk-pressure wrapper), and capture recovery and native-crash recovery freeze each part with it: a crashed multi-window session rebuilds one replay per window. diff --git a/packages/core/src/file-chunk-backend.test.ts b/packages/core/src/file-chunk-backend.test.ts index e924b549..11a17750 100644 --- a/packages/core/src/file-chunk-backend.test.ts +++ b/packages/core/src/file-chunk-backend.test.ts @@ -268,3 +268,27 @@ describe('createFileChunkBackend — part lane in the durable meta', () => { expect(parts[1]).not.toHaveProperty('lane'); }); }); + +describe('createFileChunkBackend — the lane survives a meta that cannot be read back', () => { + it('closePart writes the lane even when the open-time meta never reached the medium', async () => { + const storage = createInMemoryChunkStorage(); + const written: string[] = []; + const flaky = { + ...storage, + write: (gen: number, n: number, file: string, content: string) => { + if (file === 'meta') written.push(content); + storage.write(gen, n, file, content); + }, + read: (gen: number, n: number, file: string) => + file === 'meta' ? undefined : storage.read(gen, n, file), + }; + const b = createFileChunkBackend(flaky as never, { generation: 5 }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.closePart(ref(5, 0), 2, 7); + expect(JSON.parse(written[written.length - 1] as string)).toMatchObject({ + s: 1, + e: 2, + l: 'win-3', + }); + }); +}); diff --git a/packages/core/src/file-chunk-backend.ts b/packages/core/src/file-chunk-backend.ts index a9e96acb..ac335a41 100644 --- a/packages/core/src/file-chunk-backend.ts +++ b/packages/core/src/file-chunk-backend.ts @@ -73,6 +73,8 @@ export function createFileChunkBackend( // The part's start, kept only while it is open so closePart can rewrite a complete meta record. Keyed // by generation + number (the store only ever uses this backend's generation, but stay explicit). const openStarts = new Map(); + // The lane of each OPEN part, so closePart does not depend on reading back a meta that may never have reached the medium. + const openLanes = new Map(); const startKey = (ref: PartRef): string => `${ref.generation}/${ref.number}`; const readMeta = (gen: number, number: number): PartMeta => { @@ -100,6 +102,9 @@ export function createFileChunkBackend( openPart(ref: PartRef, start: number, lane?: string): void { openStarts.set(startKey(ref), start); + if (lane !== undefined) { + openLanes.set(startKey(ref), lane); + } writeMeta(ref.generation, ref.number, start, null, 0, lane); }, @@ -113,10 +118,15 @@ export function createFileChunkBackend( }, closePart(ref: PartRef, end: number, byteSize: number): void { - const known = readMeta(ref.generation, ref.number); - const start = openStarts.get(startKey(ref)) ?? known.start; + const open = openStarts.get(startKey(ref)); + const openLane = openLanes.get(startKey(ref)); + // Only a part this instance did not open (or whose lane it never saw) needs its meta read back. + const known = open === undefined ? readMeta(ref.generation, ref.number) : undefined; + const start = open ?? known?.start ?? 0; + const lane = openLane ?? known?.lane; openStarts.delete(startKey(ref)); - writeMeta(ref.generation, ref.number, start, end, byteSize, known.lane); + openLanes.delete(startKey(ref)); + writeMeta(ref.generation, ref.number, start, end, byteSize, lane); // A sealed chunk: let a batched storage flush + close its file handles (no-op otherwise), keeping the // open-handle count bounded to the active chunk rather than the whole rolling window. storage.sealChunk?.(ref.generation, ref.number); diff --git a/packages/electron/src/display-video.test.ts b/packages/electron/src/display-video.test.ts index af5bf929..122c658c 100644 --- a/packages/electron/src/display-video.test.ts +++ b/packages/electron/src/display-video.test.ts @@ -37,6 +37,19 @@ describe('electronDisplays', () => { ]); }); + it('gives every display a key that is its native id, whichever number it has now', () => { + const focusOn = (x: number) => + electronDisplays( + screen, + windowsOf(win(1, rect(x, 10, 100, 100), { isFocused: () => true })), + )(); + const keyOf = (list: ReturnType>, id: number) => + list.find((d) => d.id === id)?.key; + expect(keyOf(focusOn(1100), 0)).toBe(70); + expect(keyOf(focusOn(10), 0)).toBe(30); + expect(keyOf(focusOn(1100), 1)).toBe(30); + }); + it('without a focused window the lowest native id is display 0', () => { const displays = electronDisplays(screen, windowsOf(win(1, rect(1100, 10, 100, 100)))); expect(displays().map((d) => [d.id, d.bounds.x])).toEqual([ diff --git a/packages/electron/src/display-video.ts b/packages/electron/src/display-video.ts index d213b0be..747c2429 100644 --- a/packages/electron/src/display-video.ts +++ b/packages/electron/src/display-video.ts @@ -50,7 +50,7 @@ export function electronDisplays( ...native.slice(keyIndex, keyIndex + 1), ...native.filter((_, i) => i !== keyIndex), ]; - return ordered.map((display, id) => ({ id, bounds: display.bounds })); + return ordered.map((display, id) => ({ id, key: display.id, bounds: display.bounds })); }; } diff --git a/packages/electron/src/main-control.test.ts b/packages/electron/src/main-control.test.ts index e0974b78..c1e8138c 100644 --- a/packages/electron/src/main-control.test.ts +++ b/packages/electron/src/main-control.test.ts @@ -1212,4 +1212,25 @@ describe('onRendererLeft', () => { expect(() => control.rendererGone(2)).not.toThrow(); expect(control.rendererCount).toBe(1); }); + + it('survives a sender whose id cannot be read when it is destroyed, and still re-evaluates', () => { + const ipc = fakeIpcMain(); + const control = createElectronMainControl({ + ipcMain: ipc.ipcMain, + sessionId: 's1', + onRendererLeft: () => {}, + }); + control.start(); + const destroyed: Array<() => void> = []; + const r = fakeSender(5); + Object.defineProperty(r.sender, 'id', { + get() { + throw new Error('destroyed object'); + }, + }); + (r.sender as { once?: (e: string, l: () => void) => void }).once = (_e, l) => destroyed.push(l); + ipc.emit(BUGSEE_HELLO_CHANNEL, { sender: r.sender }, encodeHello()); + expect(() => destroyed[0]?.()).not.toThrow(); + expect(control.rendererCount).toBe(0); + }); }); diff --git a/packages/electron/src/main-control.ts b/packages/electron/src/main-control.ts index f10fde67..e690488b 100644 --- a/packages/electron/src/main-control.ts +++ b/packages/electron/src/main-control.ts @@ -151,12 +151,14 @@ export function createElectronMainControl( /** Drop a renderer that is gone. Idempotent — `destroyed` and a later `isDestroyed()` both land here. */ const forget = (sender: ControlSenderLike): void => { renderers.delete(sender); - if (sender.id !== undefined) { - try { - options.onRendererLeft?.(sender.id); - } catch (error) { - options.onError?.(error); + try { + // Read inside the guard: the id of a destroyed webContents can throw. + const id = sender.id; + if (id !== undefined) { + options.onRendererLeft?.(id); } + } catch (error) { + options.onError?.(error); } }; diff --git a/packages/electron/src/main-receiver.test.ts b/packages/electron/src/main-receiver.test.ts index 9343a550..96203fc6 100644 --- a/packages/electron/src/main-receiver.test.ts +++ b/packages/electron/src/main-receiver.test.ts @@ -232,4 +232,14 @@ describe('createElectronMainReceiver — one visual lane per window', () => { it('leaves a replay record unkeyed when the sender cannot be identified', () => { expect(run({}, 'replay')).not.toHaveProperty('lane'); }); + + it('drops a replay record from a sender that is already destroyed (a closing window cannot revive its retired lane)', () => { + expect(run({ sender: { id: 7, isDestroyed: () => true } as never }, 'replay')).toBeUndefined(); + }); + + it('keeps data records from a destroyed sender (only the visual lane is tied to the window)', () => { + expect(run({ sender: { id: 7, isDestroyed: () => true } as never }, 'log')).toMatchObject({ + type: 'log', + }); + }); }); diff --git a/packages/electron/src/main-receiver.ts b/packages/electron/src/main-receiver.ts index c3566e2c..90cd0fe0 100644 --- a/packages/electron/src/main-receiver.ts +++ b/packages/electron/src/main-receiver.ts @@ -15,7 +15,7 @@ import { /** An Electron `ipcMain` event (we only read the sender's id, to tag the originating window). */ export interface IpcMainEventLike { - sender?: { id?: number }; + sender?: { id?: number; isDestroyed?(): boolean }; } /** The subset of Electron's `ipcMain` we use. */ export type IpcMainListener = (event: IpcMainEventLike, ...args: unknown[]) => void; @@ -75,6 +75,11 @@ export function createElectronMainReceiver( if (decoded === undefined) { return; } + // A window that is already destroyed cannot take part in the visual lane any more: a late replay flush must not revive + // the lane the control just retired. + if (decoded.type === 'replay' && event.sender?.isDestroyed?.() === true) { + return; + } options.store.add({ type: decoded.type, timestamp: decoded.timestamp, diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index 88ec245c..d299d0a9 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -533,4 +533,143 @@ describe('createDisplayVideoSource — one composed frame per display', () => { it('returns no entries before anything was captured', async () => { expect(await setup().source.snapshot(5)).toEqual([]); }); + + it('keeps each physical display in its own ring when the ids move (focus changed monitors)', async () => { + const s = fakeScheduler(); + let displays = [ + { id: 0, key: 'A', bounds: rect(0, 0, 100, 100) }, + { id: 1, key: 'B', bounds: rect(100, 0, 100, 100) }, + ]; + const source = createDisplayVideoSource({ + displays: () => displays, + layers: (d) => [{ bounds: d.bounds, capture: async () => new Uint8Array([1]) }], + compose: async (d) => new Uint8Array([(d.key as string).charCodeAt(0)]), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => 1000, + }); + source.start(); + await s.tick(); + displays = [ + { id: 0, key: 'B', bounds: rect(100, 0, 100, 100) }, // focus moved: B is now display 0 + { id: 1, key: 'A', bounds: rect(0, 0, 100, 100) }, + ]; + await s.tick(); + const out = await source.snapshot(5); + expect(out.map((e) => [e.lane, String.fromCharCode(...(e.data as Uint8Array))])).toEqual([ + ['0', 'BB'], + ['1', 'AA'], + ]); + }); + + it('a grab that never answers is abandoned after stuckMs, so capture resumes', async () => { + let time = 1000; + let hang = true; + const s = fakeScheduler(); + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => [ + { + bounds: rect(0, 0, 10, 10), + capture: () => + hang ? new Promise(() => {}) : Promise.resolve(new Uint8Array([7])), + }, + ], + compose: async (_d, layers) => layers[0]?.bytes as Uint8Array, + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => time, + stuckMs: 5000, + }); + source.start(); + void s.tick(); // hangs + time = 1000 + 4999; + hang = false; + await s.tick(); // still inside stuckMs: skipped + expect(await source.snapshot(5)).toEqual([]); + time = 1000 + 5000; + await s.tick(); // the stuck tick is abandoned and this one captures + expect((await source.snapshot(5)).map((e) => [...(e.data as Uint8Array)])).toEqual([[7]]); + }); + + it('an abandoned tick neither adds its late frame nor frees the busy flag of the tick that replaced it', async () => { + let time = 1000; + const s = fakeScheduler(); + const grabs: Array<(bytes: Uint8Array) => void> = []; + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => [ + { + bounds: rect(0, 0, 10, 10), + capture: () => new Promise((resolve) => grabs.push(resolve)), + }, + ], + compose: async (_d, layers) => layers[0]?.bytes as Uint8Array, + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => time, + stuckMs: 5000, + }); + source.start(); + const first = s.tick(); // grab #0 hangs + time = 6000; + const second = s.tick(); // abandons the first, grab #1 is pending + await Promise.resolve(); + grabs[0]?.(new Uint8Array([9])); // the abandoned grab finally answers + await first; + time = 6001; + await s.tick(); // the second tick is still running: this one must be skipped, not start grab #2 + expect(grabs).toHaveLength(2); + grabs[1]?.(new Uint8Array([7])); + await second; + expect((await source.snapshot(5)).map((e) => [...(e.data as Uint8Array)])).toEqual([[7]]); + }); + + it('a displays() that throws is reported and the timer survives (no rejection reaches the scheduler)', async () => { + const errors: unknown[] = []; + let broken = true; + const s = fakeScheduler(); + const source = createDisplayVideoSource({ + displays: () => { + if (broken) throw new Error('screen not ready'); + return [{ id: 0, bounds: rect(0, 0, 10, 10) }]; + }, + layers: () => [{ bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([1]) }], + compose: async () => new Uint8Array([2]), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => 1000, + onError: (e) => errors.push(e), + }); + source.start(); + await expect(s.tick()).resolves.toBeUndefined(); + expect(errors).toHaveLength(1); + broken = false; + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0']); + }); + + it('an encode that throws for one display costs only that display its video', async () => { + const errors: unknown[] = []; + const s = fakeScheduler(); + const source = createDisplayVideoSource({ + displays: () => [ + { id: 0, bounds: rect(0, 0, 10, 10) }, + { id: 1, bounds: rect(10, 0, 10, 10) }, + ], + layers: (d) => [{ bounds: d.bounds, capture: async () => new Uint8Array([d.id]) }], + compose: async (d) => new Uint8Array([d.id]), + encode: (frames) => { + if (frames[0]?.bytes[0] === 0) throw new Error('mux failed'); + return new Uint8Array([9]); + }, + scheduler: s.scheduler, + now: () => 1000, + onError: (e) => errors.push(e), + }); + source.start(); + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['1']); + expect(errors).toHaveLength(1); + }); }); diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index 87133bf3..48408156 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -175,6 +175,12 @@ export interface Rect { /** One display: its id (`displays` trace order: 0 = the key window's display) and its desktop bounds. */ export interface DisplayInfo { id: number; + /** + * A key that names the PHYSICAL display and does not change when the ids are renumbered (focus moving between monitors + * renumbers `id`, since 0 is the key window's display). Frames are kept per key, so a ring never mixes two monitors. + * Default: `id`. + */ + key?: string | number; bounds: Rect; } @@ -204,6 +210,11 @@ export interface DisplayVideoSourceOptions { fps?: number; /** Max frames retained PER DISPLAY (drop-oldest). Default 300. */ maxFrames?: number; + /** + * A tick that has not finished after this many ms (a `capturePage` of an occluded or hung window never answers) is + * abandoned, so capture resumes at the next tick; its late result is discarded. Default 10000. + */ + stuckMs?: number; now: () => number; /** Sink for a failed grab or compose — the timer never throws. */ onError?: (error: unknown) => void; @@ -217,10 +228,14 @@ export interface DisplayVideoSourceOptions { export function createDisplayVideoSource(options: DisplayVideoSourceOptions): VideoCaptureSource { const fps = options.fps ?? 1; const maxFrames = options.maxFrames ?? 300; - const rings = new Map(); + const stuckMs = options.stuckMs ?? 10_000; + // Per PHYSICAL display (its key); `id` is the number it has now, refreshed every tick. + const rings = new Map(); let handle: unknown; let epoch = 0; // bumped by clear(): a frame that began before it is discarded let busy = false; // a slow compose must not overlap the next tick + let busySince = 0; + let token = 0; // identifies the running tick: an abandoned one is told apart from the current one const grab = async ( layers: readonly WindowLayer[], @@ -238,21 +253,23 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi return grabbed.filter((layer) => layer !== undefined); }; - const frameOf = async (display: DisplayInfo, started: number): Promise => { + const frameOf = async (display: DisplayInfo, started: number, mine: number): Promise => { try { const layers = await grab(options.layers(display)); if (layers.length === 0) { return; } const bytes = await options.compose(display, layers); - if (started !== epoch) { - return; + if (started !== epoch || mine !== token) { + return; // cleared (blackout) or abandoned while this frame was in flight } - const ring = rings.get(display.id) ?? []; - rings.set(display.id, ring); - ring.push({ timestamp: options.now(), bytes }); - if (ring.length > maxFrames) { - ring.shift(); + const key = display.key ?? display.id; + const ring = rings.get(key) ?? { id: display.id, frames: [] }; + ring.id = display.id; + rings.set(key, ring); + ring.frames.push({ timestamp: options.now(), bytes }); + if (ring.frames.length > maxFrames) { + ring.frames.shift(); } } catch (error) { options.onError?.(error); @@ -260,22 +277,37 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi }; const tick = async (): Promise => { - if (busy) { - return; - } - busy = true; - const started = epoch; try { - await Promise.all(options.displays().map((display) => frameOf(display, started))); - // A display that is gone keeps its frames only as long as the ring covers: after that nothing of it is in the window. - const cutoff = options.now() - (maxFrames / fps) * 1000; - for (const [id, ring] of rings) { - if ((ring[ring.length - 1]?.timestamp ?? 0) < cutoff) { - rings.delete(id); + if (busy) { + if (options.now() - busySince < stuckMs) { + return; } + // The running tick has not answered for stuckMs: abandon it (its result is ignored via the token). } - } finally { - busy = false; + busy = true; + busySince = options.now(); + token += 1; + const mine = token; + const started = epoch; + try { + await Promise.all(options.displays().map((display) => frameOf(display, started, mine))); + if (mine !== token) { + return; + } + // A display that is gone keeps its frames only as long as the ring covers: after that nothing of it is in the window. + const cutoff = options.now() - (maxFrames / fps) * 1000; + for (const [key, ring] of rings) { + if ((ring.frames[ring.frames.length - 1]?.timestamp ?? 0) < cutoff) { + rings.delete(key); + } + } + } finally { + if (mine === token) { + busy = false; + } + } + } catch (error) { + options.onError?.(error); // a screen that is not ready, a throwing clock: the timer must never see a rejection } }; @@ -298,14 +330,17 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi rings.clear(); }, async snapshot(now: number): Promise { - return [...rings.entries()] - .filter(([, ring]) => ring.length > 0) - .sort(([a], [b]) => a - b) - .map(([id, ring]) => { - const entry = videoEntry(now, options.encode([...ring], fps)); + const out: CaptureDataEntry[] = []; + for (const { id, frames } of [...rings.values()].sort((a, b) => a.id - b.id)) { + try { + const entry = videoEntry(now, options.encode([...frames], fps)); // a ring exists only with its first frame entry.lane = String(id); - return entry; - }); + out.push(entry); + } catch (error) { + options.onError?.(error); // one display's muxer failing costs only that display's video + } + } + return out; }, }; } From b631a597286efa7fbc8f7fbcd43c94adaf7c8b73 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 14:15:17 +0500 Subject: [PATCH 25/33] test(instrumentation): raise the webview bundle budget to 204 KiB raw / 69 KiB gzip for the per-source visual lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared core grew about 1 KB raw with lane routing, per-lane bundle members and the lane in the part meta; the webview bundles it without using it. Raised on purpose, with the measurement recorded, as the guard's own comment requires. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../instrumentation-tests/test/webview-bundle.e2e.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/instrumentation-tests/test/webview-bundle.e2e.ts b/packages/instrumentation-tests/test/webview-bundle.e2e.ts index 9c59f3d6..a6e32720 100644 --- a/packages/instrumentation-tests/test/webview-bundle.e2e.ts +++ b/packages/instrumentation-tests/test/webview-bundle.e2e.ts @@ -32,9 +32,12 @@ const KB = 1024; // duplicate-bundled or heavy new dep. It tracks real growth, so it is raised on purpose, never silently: it was 64 KiB while // the bundle was ~22 KB gzip, and is now 68 KiB (69,632 bytes) because the bundle has grown to ~64-65 KB gzip with the route // and view traces, the wire-name registry and the host-owned trace suppression (the live set, its table and the handshake -// field cost ~340 bytes gzip). The artifact size stays logged by the test below. -const GZIP_BUDGET = 68 * KB; -const RAW_BUDGET = 200 * KB; +// field cost ~340 bytes gzip). Raised again, on purpose (2026-11-01), to 69 KiB gzip / 204 KiB raw: the shared core grew by about +// 1 KB raw (~0.2 KB gzip) with the per-source visual lanes (lane routing in the chunk store, the per-lane members in the bundle +// assembler, the lane in the part meta), which the webview bundles but does not use; the bundle measured 201.0 KB raw / 67.8 KB +// gzip against the old 200 / 68 limits. The artifact size stays logged by the test below. +const GZIP_BUDGET = 69 * KB; +const RAW_BUDGET = 204 * KB; const IIFE_ENTRY = fileURLToPath(new URL('../../webview/src/iife.ts', import.meta.url)); From adc0fba7d7272431ba18c3153447b2756e4b219d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 16:14:11 +0500 Subject: [PATCH 26/33] test(core): give the two heavy sink back-pressure tests a 120 s limit; the CI coverage runner is ~40x slower and they timed out at 30 s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../core/src/bundle-stream-assembler.test.ts | 121 ++++++++++-------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/packages/core/src/bundle-stream-assembler.test.ts b/packages/core/src/bundle-stream-assembler.test.ts index 0370bfaa..2b1c3f44 100644 --- a/packages/core/src/bundle-stream-assembler.test.ts +++ b/packages/core/src/bundle-stream-assembler.test.ts @@ -983,6 +983,9 @@ describe('assembleBundleStream — sink', () => { }); // Incompressible text, so the zip output is as big as the input and a slow sink actually backs up. + // These tests deflate ~10 MB of incompressible text: ~1 s locally, but the coverage-instrumented CI runner is ~40x slower and + // the default 30 s limit flaked (this file took 46 s there even when green). + const SLOW_RUNNER_TIMEOUT_MS = 120_000; const noise = (seed: number, length: number): string => { let x = seed; let out = ''; @@ -993,35 +996,39 @@ describe('assembleBundleStream — sink', () => { return out; }; - it('does not read ahead of a slow sink: the backlog stays bounded', async () => { - let pulled = 0; - async function* source(): AsyncIterable { - for (let i = 0; i < 30; i++) { - pulled += 1; - yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + it( + 'does not read ahead of a slow sink: the backlog stays bounded', + async () => { + let pulled = 0; + async function* source(): AsyncIterable { + for (let i = 0; i < 30; i++) { + pulled += 1; + yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + } } - } - const { sink, written, maxInFlight } = fakeSink({ slow: true }); - const pulledAtWrite: number[] = []; - const slowSink = { - open: async () => { - const writer = await sink.open(); - return { - ...writer, - write: async (chunk: Uint8Array) => { - pulledAtWrite.push(pulled); - await writer.write(chunk); - }, - }; - }, - }; - await assembleBundleStream(request(), source(), context({ sink: slowSink })); - expect(written.length).toBeGreaterThan(1); - expect(maxInFlight()).toBe(1); // writes are strictly sequential - // Written chunks lag the reader by at most the backlog bound (a few entries), never the whole export. - expect(Math.min(...pulledAtWrite.slice(0, 5))).toBeLessThan(30); - expect(pulledAtWrite[0]).toBeLessThanOrEqual(8); - }); + const { sink, written, maxInFlight } = fakeSink({ slow: true }); + const pulledAtWrite: number[] = []; + const slowSink = { + open: async () => { + const writer = await sink.open(); + return { + ...writer, + write: async (chunk: Uint8Array) => { + pulledAtWrite.push(pulled); + await writer.write(chunk); + }, + }; + }, + }; + await assembleBundleStream(request(), source(), context({ sink: slowSink })); + expect(written.length).toBeGreaterThan(1); + expect(maxInFlight()).toBe(1); // writes are strictly sequential + // Written chunks lag the reader by at most the backlog bound (a few entries), never the whole export. + expect(Math.min(...pulledAtWrite.slice(0, 5))).toBeLessThan(30); + expect(pulledAtWrite[0]).toBeLessThanOrEqual(8); + }, + SLOW_RUNNER_TIMEOUT_MS, + ); it('stops reading entries once the sink has failed', async () => { let pulled = 0; @@ -1038,34 +1045,38 @@ describe('assembleBundleStream — sink', () => { expect(pulled).toBeLessThan(5); }); - it('holds the read-ahead to a bounded backlog while the sink is blocked, then completes once it is released', async () => { - let pulled = 0; - async function* source(): AsyncIterable { - for (let i = 0; i < 40; i++) { - pulled += 1; - yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + it( + 'holds the read-ahead to a bounded backlog while the sink is blocked, then completes once it is released', + async () => { + let pulled = 0; + async function* source(): AsyncIterable { + for (let i = 0; i < 40; i++) { + pulled += 1; + yield entry('log', NOWS - 100 + i, noise(i + 1, 300_000)); + } } - } - let open!: () => void; - const gate = new Promise((resolve) => (open = resolve)); - const written: Uint8Array[] = []; - const sink = { - open: () => ({ - write: async (chunk: Uint8Array) => { - await gate; - written.push(chunk); - }, - finish: async () => createMemoryBody(written), - abort: async () => {}, - }), - }; - const pending = assembleBundleStream(request(), source(), context({ sink })); - for (let i = 0; i < 200; i++) await Promise.resolve(); - expect(pulled).toBeLessThan(40); // blocked sink: the reader did not run away with the whole export - open(); - await pending; - expect(pulled).toBe(40); - }); + let open!: () => void; + const gate = new Promise((resolve) => (open = resolve)); + const written: Uint8Array[] = []; + const sink = { + open: () => ({ + write: async (chunk: Uint8Array) => { + await gate; + written.push(chunk); + }, + finish: async () => createMemoryBody(written), + abort: async () => {}, + }), + }; + const pending = assembleBundleStream(request(), source(), context({ sink })); + for (let i = 0; i < 200; i++) await Promise.resolve(); + expect(pulled).toBeLessThan(40); // blocked sink: the reader did not run away with the whole export + open(); + await pending; + expect(pulled).toBe(40); + }, + SLOW_RUNNER_TIMEOUT_MS, + ); it('stops writing to a sink after its first failed write', async () => { let calls = 0; From 4839694ece6952bf979806bba3ee92b9ef68e153 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 22:17:39 +0500 Subject: [PATCH 27/33] fix(core,node,electron,replay): main builds replay.bin through the replay stream encoder so windows split (also in recovery); the file store forwards onVisualReset; display video review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch() takes streamEncoders (live export and recovery context); launchMain supplies the replay encoder via the new @bugsee/replay/stream-encoder subpath (no rrweb in main) - createFileCaptureStore forwards onVisualReset (it was dropped, so the per-window checkout never went out on the default store) - file backend forgets an open part's lane on removePart/removeGeneration/unkeyed reopen - electronDisplays: trace key-window rule and largest-overlap display - display video: per-window capture bound, ring id refresh before the empty-layer return, expired frames trimmed, guarded onError, fps validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/per-window-replay.md | 13 ++ packages/core/src/file-capture-store.test.ts | 17 ++ packages/core/src/file-capture-store.ts | 3 + packages/core/src/file-chunk-backend.test.ts | 37 ++++ packages/core/src/file-chunk-backend.ts | 14 ++ packages/electron/package.json | 1 + packages/electron/src/display-video.test.ts | 64 ++++++- packages/electron/src/display-video.ts | 37 ++-- packages/electron/src/launch-main.test.ts | 23 +++ packages/electron/src/launch-main.ts | 9 + .../src/main-replay-lanes.integration.test.ts | 98 +++++++++++ packages/electron/src/video-capture.test.ts | 162 ++++++++++++++++++ packages/electron/src/video-capture.ts | 54 +++++- .../node/src/launch-recovery-encoders.test.ts | 87 ++++++++++ packages/node/src/launch.test.ts | 33 ++++ packages/node/src/launch.ts | 17 +- packages/replay/package.json | 14 ++ packages/replay/tsup.config.ts | 3 +- pnpm-lock.yaml | 3 + 19 files changed, 667 insertions(+), 22 deletions(-) create mode 100644 packages/electron/src/main-replay-lanes.integration.test.ts create mode 100644 packages/node/src/launch-recovery-encoders.test.ts diff --git a/docs/design/per-window-replay.md b/docs/design/per-window-replay.md index dfbf2971..53b0f11e 100644 --- a/docs/design/per-window-replay.md +++ b/docs/design/per-window-replay.md @@ -58,6 +58,19 @@ base across windows, and a byte-cap reset restarts every recorder. The replay of process is gone; the store drops it once none of its data is inside the recording window (so the report range is kept whole), and any new record for the lane revives it (a crashed window that reloads keeps its id). +* **Review of PR #173 (2026-11-01)** found that the split never reached a real Electron export: neither `@bugsee/node` nor + `launchMain` passed the replay stream encoder, and a type without an encoder is written merged (one member, all windows). Now + `launch()` takes `streamEncoders` (live export AND the recovery context, so a recovered multi-window replay splits too), + `launchMain` supplies `createReplayStreamEncoder()` (imported from the new `@bugsee/replay/stream-encoder` subpath: fflate and + core types only, no rrweb in main; a caller's own encoder wins), and an integration test drives two windows through the real + receiver, chunk store, exporter, assembler and encoder. Also fixed: the file store forwards `onVisualReset` (it was dropped on + the default Node/Electron store, so the per-window `checkout` never went out), the file backend forgets an open part's lane when + the part or its generation is removed, `electronDisplays` follows the trace's key-window rule (focused, last focused, first + visible by id, first by id; display = largest overlap), a window whose `capture()` never answers is dropped from its display's + frame after `layerTimeoutMs` (a hung window no longer stalls every display), a ring refreshes its display number before the + empty-layer return, expired frames are trimmed from a live ring, a throwing `onError` cannot reject the timer, and an `fps` that + is not a positive finite number is rejected. + Not shipped: a concrete `compose` (painting the grabbed windows onto one display-sized frame needs an image library or an offscreen canvas; the package stays free of both, so the host injects it) and the viewer changes below. diff --git a/packages/core/src/file-capture-store.test.ts b/packages/core/src/file-capture-store.test.ts index cea0953d..149e4dc0 100644 --- a/packages/core/src/file-capture-store.test.ts +++ b/packages/core/src/file-capture-store.test.ts @@ -301,3 +301,20 @@ describe('createFileCaptureStore — clear', () => { expect(storage.read(5, 0, 'log')).toBe('other-gen'); // only generation 9’s chunks were cleared }); }); + +describe('createFileCaptureStore — onVisualReset', () => { + it("tells the host when the byte cap sheds a window lane's only base (the replay recorder must checkout)", async () => { + const resets: Array = []; + const store = mk(createInMemoryChunkStorage(), { + maxRecordingTimeMs: 1_000_000, + maxDataSizeBytes: 2 * U - 1, + onVisualReset: (lane) => resets.push(lane), + }); + store.add({ ...recBytes('replay', 10_000), anchor: true, lane: '7' }); + store.tick(11_000); + store.add(recBytes('log', 11_000)); // 2 records > the cap: the lane's lone base goes + await Promise.resolve(); + await Promise.resolve(); + expect(resets).toEqual(['7']); + }); +}); diff --git a/packages/core/src/file-capture-store.ts b/packages/core/src/file-capture-store.ts index 24173bae..345b2b10 100644 --- a/packages/core/src/file-capture-store.ts +++ b/packages/core/src/file-capture-store.ts @@ -30,6 +30,8 @@ export interface FileCaptureStoreOptions { onDiskWriteError?: (error: unknown) => void; /** See ChunkCaptureStoreOptions.onDiskReadError. */ onDiskReadError?: (error: unknown) => void; + /** See ChunkCaptureStoreOptions.onVisualReset: told when the byte cap sheds the last base of a visual lane. */ + onVisualReset?: (lane?: string) => void; } export function createFileCaptureStore( @@ -51,6 +53,7 @@ export function createFileCaptureStore( ? { onDiskWriteError: options.onDiskWriteError } : {}), ...(options?.onDiskReadError !== undefined ? { onDiskReadError: options.onDiskReadError } : {}), + ...(options?.onVisualReset !== undefined ? { onVisualReset: options.onVisualReset } : {}), }; return createChunkCaptureStore(backend, storeOptions); } diff --git a/packages/core/src/file-chunk-backend.test.ts b/packages/core/src/file-chunk-backend.test.ts index 11a17750..b684aac6 100644 --- a/packages/core/src/file-chunk-backend.test.ts +++ b/packages/core/src/file-chunk-backend.test.ts @@ -292,3 +292,40 @@ describe('createFileChunkBackend — the lane survives a meta that cannot be rea }); }); }); + +describe('createFileChunkBackend — an open part that is removed leaves no lane behind', () => { + const metaOf = (storage: ReturnType, n: number) => + JSON.parse(storage.read(5, n, 'meta') as string) as Record; + + it('removePart of an open laned part: a later unkeyed part with the same ref is not given that lane', () => { + const storage = createInMemoryChunkStorage(); + const b = createFileChunkBackend(storage, { generation: 5 }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.removePart(ref(5, 0)); + b.openPart(ref(5, 0), 3); + b.closePart(ref(5, 0), 4, 0); + expect(metaOf(storage, 0)).not.toHaveProperty('l'); + }); + + it('removeGeneration forgets the lanes of that generation only', () => { + const storage = createInMemoryChunkStorage(); + const b = createFileChunkBackend(storage, { generation: 5, cleanOtherGenerations: false }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.openPart(ref(50, 0), 1, 'win-9'); // another generation whose number starts with 5 + b.removeGeneration(5); + b.openPart(ref(5, 0), 3); // reopened without a lane + b.closePart(ref(5, 0), 4, 0); + b.closePart(ref(50, 0), 4, 0); + expect(metaOf(storage, 0)).not.toHaveProperty('l'); + expect(JSON.parse(storage.read(50, 0, 'meta') as string)).toMatchObject({ l: 'win-9' }); + }); + + it('an unkeyed openPart over a ref whose lane was never closed starts with no lane', () => { + const storage = createInMemoryChunkStorage(); + const b = createFileChunkBackend(storage, { generation: 5 }); + b.openPart(ref(5, 0), 1, 'win-3'); + b.openPart(ref(5, 0), 3); // the same ref reopened unkeyed + b.closePart(ref(5, 0), 4, 0); + expect(metaOf(storage, 0)).not.toHaveProperty('l'); + }); +}); diff --git a/packages/core/src/file-chunk-backend.ts b/packages/core/src/file-chunk-backend.ts index ac335a41..aa98d671 100644 --- a/packages/core/src/file-chunk-backend.ts +++ b/packages/core/src/file-chunk-backend.ts @@ -104,6 +104,8 @@ export function createFileChunkBackend( openStarts.set(startKey(ref), start); if (lane !== undefined) { openLanes.set(startKey(ref), lane); + } else { + openLanes.delete(startKey(ref)); // a reopened ref must not inherit the lane of its previous life } writeMeta(ref.generation, ref.number, start, null, 0, lane); }, @@ -155,10 +157,22 @@ export function createFileChunkBackend( removePart(ref: PartRef): void { openStarts.delete(startKey(ref)); + openLanes.delete(startKey(ref)); storage.removeChunk(ref.generation, ref.number); }, removeGeneration(gen: number): void { + // The open parts of a removed generation are never closed: forget their starts and lanes with it. + for (const key of [...openStarts.keys()]) { + if (key.startsWith(`${gen}/`)) { + openStarts.delete(key); + } + } + for (const key of [...openLanes.keys()]) { + if (key.startsWith(`${gen}/`)) { + openLanes.delete(key); + } + } storage.removeGeneration(gen); }, diff --git a/packages/electron/package.json b/packages/electron/package.json index 1dd53ca6..2004d1f8 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -45,6 +45,7 @@ "@bugsee/core": "workspace:*", "@bugsee/node": "workspace:*", "@bugsee/protocol": "workspace:*", + "@bugsee/replay": "workspace:*", "@bugsee/trackers": "workspace:*" }, "devDependencies": { diff --git a/packages/electron/src/display-video.test.ts b/packages/electron/src/display-video.test.ts index 122c658c..ff7677dc 100644 --- a/packages/electron/src/display-video.test.ts +++ b/packages/electron/src/display-video.test.ts @@ -50,8 +50,68 @@ describe('electronDisplays', () => { expect(keyOf(focusOn(1100), 1)).toBe(30); }); - it('without a focused window the lowest native id is display 0', () => { - const displays = electronDisplays(screen, windowsOf(win(1, rect(1100, 10, 100, 100)))); + it('numbers the display that most overlaps a window straddling two displays (as getDisplayMatching does)', () => { + const displays = electronDisplays( + screen, + windowsOf(win(1, rect(900, 10, 400, 100), { isFocused: () => true })), // 100 px on the left display, 300 on the right + ); + expect(displays().map((d) => d.key)).toEqual([70, 30]); + }); + + it('falls back to the first created visible window when none is focused and none was', () => { + const displays = electronDisplays( + screen, + windowsOf( + win(3, rect(0, 10, 100, 100)), + win(2, rect(1100, 10, 100, 100)), + win(1, rect(0, 10, 100, 100), { isVisible: () => false }), + ), + ); + expect(displays().map((d) => d.key)).toEqual([70, 30]); // window 2: the lowest id that is visible + }); + + it('falls back to the first created window when none is visible', () => { + const displays = electronDisplays( + screen, + windowsOf( + win(5, rect(0, 10, 100, 100), { isVisible: () => false }), + win(4, rect(1100, 10, 100, 100), { isVisible: () => false }), + ), + ); + expect(displays().map((d) => d.key)).toEqual([70, 30]); + }); + + it('keeps the display of the window that was focused last while nothing is focused now', () => { + let focusedId: number | undefined = 2; + const windows = [ + win(1, rect(0, 10, 100, 100), { isFocused: () => focusedId === 1 }), + win(2, rect(1100, 10, 100, 100), { isFocused: () => focusedId === 2 }), + ]; + const displays = electronDisplays(screen, windowsOf(...windows)); + expect(displays().map((d) => d.key)).toEqual([70, 30]); + focusedId = undefined; // the app lost focus: window 2 is still the key window + expect(displays().map((d) => d.key)).toEqual([70, 30]); + }); + + it('forgets a last-focused window that was destroyed', () => { + let destroyed = false; + let focused = true; + const windows = [ + win(1, rect(0, 10, 100, 100)), + win(2, rect(1100, 10, 100, 100), { + isFocused: () => focused, + isDestroyed: () => destroyed, + }), + ]; + const displays = electronDisplays(screen, windowsOf(...windows)); + expect(displays().map((d) => d.key)).toEqual([70, 30]); + focused = false; + destroyed = true; + expect(displays().map((d) => d.key)).toEqual([30, 70]); // window 1 now + }); + + it('without any window the lowest native id is display 0', () => { + const displays = electronDisplays(screen, windowsOf()); expect(displays().map((d) => [d.id, d.bounds.x])).toEqual([ [0, 0], [1, 1000], diff --git a/packages/electron/src/display-video.ts b/packages/electron/src/display-video.ts index 747c2429..dfa34e39 100644 --- a/packages/electron/src/display-video.ts +++ b/packages/electron/src/display-video.ts @@ -26,26 +26,41 @@ export interface ElectronBrowserWindowLike { const overlaps = (a: Rect, b: Rect): boolean => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +const intersectionArea = (a: Rect, b: Rect): number => { + const width = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x); + const height = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y); + return width > 0 && height > 0 ? width * height : 0; +}; + /** - * The displays as the `displays` trace numbers them: id 0 is the display of the focused window (the key window), the others - * follow in ascending native id. Without a focused window the lowest native id is 0. + * The displays as the `displays` trace numbers them: id 0 is the display that most overlaps the KEY window + * (`screen.getDisplayMatching`), the others follow in ascending native id. The key window is the focused one, else the one + * that was focused last, else the first created visible window, else the first created (the application tracker's rule). */ export function electronDisplays( screen: ScreenLike, BrowserWindow: ElectronBrowserWindowLike, ): () => DisplayInfo[] { + let lastFocused: WindowLike | undefined; + const usable = (window: WindowLike): boolean => window.isDestroyed?.() !== true; return () => { const native = [...screen.getAllDisplays()].sort((a, b) => a.id - b.id); - const focused = BrowserWindow.getAllWindows().find( - (window) => window.isDestroyed?.() !== true && window.isFocused(), + const windows = BrowserWindow.getAllWindows().filter(usable); + const focused = windows.find((window) => window.isFocused()); + if (focused !== undefined) { + lastFocused = focused; + } + const created = [...windows].sort((a, b) => a.id - b.id); + const key = + focused ?? + (lastFocused !== undefined && usable(lastFocused) ? lastFocused : undefined) ?? + created.find((window) => window.isVisible()) ?? + created[0]; + const bounds = key?.getBounds(); + const areas = native.map((display) => + bounds === undefined ? 0 : intersectionArea(display.bounds, bounds), ); - const keyIndex = - focused === undefined - ? 0 - : Math.max( - 0, - native.findIndex((display) => overlaps(display.bounds, focused.getBounds())), - ); + const keyIndex = Math.max(0, areas.indexOf(Math.max(...areas))); const ordered = [ ...native.slice(keyIndex, keyIndex + 1), ...native.filter((_, i) => i !== keyIndex), diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index 183f4ce7..e45e9c5f 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -564,6 +564,29 @@ describe('launchMain', () => { expect(onError).toHaveBeenCalled(); // the cleanup error was routed, not propagated }); + it('hands the replay stream encoder to the node launch, so the report splits one replay member per window', () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + launchMain('tok', { ipcMain: ipc.ipcMain, launch: f.launch }); + const opts = f.received?.options as BugseeLaunchOptions; + const replay = opts.streamEncoders?.replay; + expect(typeof replay?.begin).toBe('function'); + // the real encoder: a session that saw no base writes nothing + expect(replay?.begin({ start: undefined, end: 1 }).end()).toEqual([]); + }); + + it('a stream encoder the caller passed wins over the default one', () => { + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + const mine = { begin: () => ({ write: () => [], end: () => [] }) }; + launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + streamEncoders: { replay: mine }, + }); + expect((f.received?.options as BugseeLaunchOptions).streamEncoders?.replay).toBe(mine); + }); + it('omits pixel-video wiring entirely when no video option is given', () => { const ipc = fakeIpcMain(); const f = fakeLaunch(); diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 52c22c8f..4c2f8419 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -106,6 +106,7 @@ export interface ElectronAppLike { import type { CrashpadSessionMarker } from '@bugsee/core'; import { TraceName } from '@bugsee/protocol'; +import { createReplayStreamEncoder } from '@bugsee/replay/stream-encoder'; import { reportGuarded, reportOncePerMessage } from '@bugsee/util'; import { type ControlSenderLike, @@ -278,6 +279,14 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee })); } + // The renderers' rrweb replay arrives here as records, one lane per window: building `replay.bin` needs the replay's stream + // encoder, which also splits the type into one member per window lane (a type without an encoder is written merged). The + // encoder alone is imported (no rrweb in main). A caller's own encoder wins. + nodeOptions.streamEncoders = { + replay: createReplayStreamEncoder(), + ...nodeOptions.streamEncoders, + }; + // Opt-in pixel video: build the permission-gated controller and forward its report-time snapshot + // the `video` binary encoder into the node launch (merged with any the caller passed directly). let videoController: PixelVideoController | undefined; diff --git a/packages/electron/src/main-replay-lanes.integration.test.ts b/packages/electron/src/main-replay-lanes.integration.test.ts new file mode 100644 index 00000000..e06cb865 --- /dev/null +++ b/packages/electron/src/main-replay-lanes.integration.test.ts @@ -0,0 +1,98 @@ +import { gunzipSync } from 'node:zlib'; +import { + assembleBundleStream, + createCaptureExporter, + createChunkCaptureStore, + createMemoryChunkBackend, + createReportingRequest, +} from '@bugsee/core'; +import { createReplayStreamEncoder } from '@bugsee/replay/stream-encoder'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { describe, expect, it } from 'vitest'; +import { createElectronMainReceiver, type IpcMainListener } from './main-receiver'; +import { encodeStreamEntry } from './protocol'; + +// Two windows stream rrweb replay into main; the report must carry one decodable replay per window, each opening on its own +// base. Real receiver -> real chunk store -> real exporter -> real assembler -> the real replay stream encoder. +const NOW = 1_700_000_000_000; +const clock = { wallNow: () => NOW, monotonicNow: () => 0 }; + +const event = (type: number, timestamp: number, tag: string) => ({ + type, + timestamp, + data: { tag }, +}); +const wire = (seq: number, timestamp: number, payload: unknown, anchor = false): string => + encodeStreamEntry({ + type: 'replay', + seq, + timestamp, + mono: 0, + timeOrigin: 0, + redacted: false, + payload: JSON.stringify({ timestamp, data: payload }), // a record's payload wraps the rrweb event as `data` + ...(anchor ? { anchor: true } : {}), + } as never); + +describe('main: one replay member per window, end to end', () => { + it('builds replay.bin and replay.1.bin from two windows, each opening on its own Meta + FullSnapshot', async () => { + const store = createChunkCaptureStore(createMemoryChunkBackend(), { + clock, + maxRecordingTimeMs: 1_000_000, + }); + let listener: IpcMainListener | undefined; + const receiver = createElectronMainReceiver({ + ipcMain: { + on: (_c, l) => { + listener = l; + }, + removeListener: () => {}, + }, + store, + }); + receiver.start(); + const send = (id: number, message: string) => listener?.({ sender: { id } }, message); + // window 3 and window 8 interleave their events + send(3, wire(1, NOW - 50, event(4, NOW - 50, 'A-meta'))); + send(8, wire(1, NOW - 49, event(4, NOW - 49, 'B-meta'))); + send(3, wire(2, NOW - 48, event(2, NOW - 48, 'A-full'), true)); + send(8, wire(2, NOW - 47, event(2, NOW - 47, 'B-full'), true)); + send(3, wire(3, NOW - 40, event(3, NOW - 40, 'A-inc'))); + send(8, wire(3, NOW - 39, event(3, NOW - 39, 'B-inc'))); + + const bundle = await assembleBundleStream( + createReportingRequest({ source: { type: 'error' }, id: 'r1' }), + createCaptureExporter(store).stream(), + { + appToken: 'tok', + environment: { + platform: { type: 'electron', version: '1' }, + runtime: { type: 'electron', version: '' }, + sdk: { version: '1', type: 'javascript' }, + } as never, + attributes: {}, + clock, + fileName: () => 'b.zip', + streamEncoders: { replay: createReplayStreamEncoder() }, + }, + ); + const files = unzipSync(await bundle.body.bytes()); + const tags = (name: string): string[] => + ( + JSON.parse(strFromU8(new Uint8Array(gunzipSync(files[name] as Uint8Array)))) as Array<{ + data: { tag: string }; + }> + ).map((e) => e.data.tag); + expect(tags('replay.bin')).toEqual(['A-meta', 'A-full', 'A-inc']); + expect(tags('replay.1.bin')).toEqual(['B-meta', 'B-full', 'B-inc']); + const manifest = JSON.parse(strFromU8(files['manifest.json'] as Uint8Array)) as { + files: Array<{ filename: string; type: string; attrs?: Record }>; + }; + expect( + manifest.files.filter((f) => f.type === 'replay').map((f) => [f.filename, f.attrs?.window]), + ).toEqual([ + ['replay.bin', '3'], + ['replay.1.bin', '8'], + ]); + }); +}); diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index d299d0a9..5275d37f 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -649,6 +649,168 @@ describe('createDisplayVideoSource — one composed frame per display', () => { expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0']); }); + it('a window whose capture never answers is dropped from its display frame after layerTimeoutMs, the others still compose', async () => { + const errors: unknown[] = []; + const s = fakeScheduler(); + const timers: Array<() => void> = []; + const composed: number[][] = []; + const source = createDisplayVideoSource({ + displays: () => [ + { id: 0, bounds: rect(0, 0, 10, 10) }, + { id: 1, bounds: rect(10, 0, 10, 10) }, + ], + layers: (display) => + display.id === 0 + ? [ + { bounds: rect(0, 0, 10, 10), capture: () => new Promise(() => {}) }, + { bounds: rect(0, 0, 5, 5), capture: async () => new Uint8Array([9]) }, + ] + : [{ bounds: rect(10, 0, 10, 10), capture: async () => new Uint8Array([4]) }], + compose: async (display, layers) => { + composed.push([display.id, ...layers.map((l) => l.bytes[0] as number)]); + return new Uint8Array([display.id]); + }, + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => 1000, + layerTimeoutMs: 2500, + after: (ms, fn) => { + expect(ms).toBe(2500); + timers.push(fn); + return () => {}; + }, + onError: (e) => errors.push(e), + }); + source.start(); + const ticking = s.tick(); + await Promise.resolve(); + await Promise.resolve(); + for (const fire of timers) fire(); // the hung window's timer fires + await ticking; + expect(composed.sort()).toEqual([ + [0, 9], + [1, 4], + ]); + expect(errors).toHaveLength(1); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0', '1']); + }); + + it('cancels the capture timer of a window that answered in time', async () => { + const s = fakeScheduler(); + const cancelled: number[] = []; + let n = 0; + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => [{ bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([1]) }], + compose: async () => new Uint8Array([2]), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => 1000, + after: () => { + const mine = n++; + return () => cancelled.push(mine); + }, + }); + source.start(); + await s.tick(); + expect(cancelled).toEqual([0]); + }); + + it('a display whose last window left gives up the id another display now holds', async () => { + let time = 1000; + const s = fakeScheduler(); + let displays = [ + { id: 0, key: 'A', bounds: rect(0, 0, 10, 10) }, + { id: 1, key: 'B', bounds: rect(10, 0, 10, 10) }, + ]; + let withWindowOnB = true; + const source = createDisplayVideoSource({ + displays: () => displays, + layers: (display) => + display.key === 'B' && !withWindowOnB + ? [] + : [{ bounds: display.bounds, capture: async () => new Uint8Array([1]) }], + compose: async () => new Uint8Array([1]), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => time, + }); + source.start(); + await s.tick(); // A = 0, B = 1 + // focus moves to B: B is 0 now, A is 1, and B has no visible window any more + displays = [ + { id: 0, key: 'B', bounds: rect(10, 0, 10, 10) }, + { id: 1, key: 'A', bounds: rect(0, 0, 10, 10) }, + ]; + withWindowOnB = false; + time = 2000; + await s.tick(); + expect((await source.snapshot(5)).map((e) => e.lane).sort()).toEqual(['0', '1']); + }); + + it('drops the frames older than the ring covers from a display that shows a window again', async () => { + let time = 1000; + const s = fakeScheduler(); + let alive = true; + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => + alive ? [{ bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([1]) }] : [], + compose: async (_d, layers) => layers[0]?.bytes as Uint8Array, + encode: (frames) => Uint8Array.from(frames.map((f) => f.timestamp / 1000)), + scheduler: s.scheduler, + now: () => time, + maxFrames: 4, // covers 4 s at 1 fps + }); + source.start(); + await s.tick(); // t = 1000: a frame + alive = false; + time = 3000; + await s.tick(); // nothing visible: no frame, the old one stays (inside the span) + alive = true; + time = 6000; + await s.tick(); // a new frame; the one at 1000 is now older than 6000 - 4000 + expect((await source.snapshot(5)).map((e) => [...(e.data as Uint8Array)])).toEqual([[6]]); + }); + + it('a throwing onError never makes the timer callback reject', async () => { + const s = fakeScheduler(); + const source = createDisplayVideoSource({ + displays: () => { + throw new Error('screen not ready'); + }, + layers: () => [], + compose: async () => new Uint8Array([2]), + encode: () => new Uint8Array(), + scheduler: s.scheduler, + now: () => 1000, + onError: () => { + throw new Error('sink broke'); + }, + }); + source.start(); + await expect(s.tick()).resolves.toBeUndefined(); + }); + + it.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + ])('rejects an fps of %s at construction', (fps) => { + expect(() => + createDisplayVideoSource({ + displays: () => [], + layers: () => [], + compose: async () => new Uint8Array(), + encode: () => new Uint8Array(), + scheduler: fakeScheduler().scheduler, + now: () => 0, + fps, + }), + ).toThrow(RangeError); + }); + it('an encode that throws for one display costs only that display its video', async () => { const errors: unknown[] = []; const s = fakeScheduler(); diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index 48408156..52df211c 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -10,6 +10,7 @@ // combination) → an encoded video blob. // The two can run together (the controller composes them); each emits an independent `video` entry. import { type CaptureDataEntry, CaptureDataEntryBase, type Scheduler } from '@bugsee/core'; +import { reportGuarded } from '@bugsee/util'; /** A single captured frame: the encoded image bytes (e.g. a JPEG/PNG from `capturePage`) + its capture time. */ export interface VideoFrame { @@ -215,8 +216,15 @@ export interface DisplayVideoSourceOptions { * abandoned, so capture resumes at the next tick; its late result is discarded. Default 10000. */ stuckMs?: number; + /** + * One window's `capture()` that has not answered after this many ms is dropped from its display's frame (the other windows + * still compose, and the other displays are not held up). Default half of `stuckMs`. + */ + layerTimeoutMs?: number; + /** One-shot timer: calls `fn` after `ms` and returns its canceller. Default the global `setTimeout`. */ + after?: (ms: number, fn: () => void) => () => void; now: () => number; - /** Sink for a failed grab or compose — the timer never throws. */ + /** Sink for a failed grab or compose — the timer never throws, whatever the sink does. */ onError?: (error: unknown) => void; } @@ -227,8 +235,23 @@ export interface DisplayVideoSourceOptions { */ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): VideoCaptureSource { const fps = options.fps ?? 1; + if (!Number.isFinite(fps) || fps <= 0) { + throw new RangeError(`fps must be a positive finite number, got ${fps}`); + } const maxFrames = options.maxFrames ?? 300; const stuckMs = options.stuckMs ?? 10_000; + const layerTimeoutMs = options.layerTimeoutMs ?? Math.floor(stuckMs / 2); + const after = + options.after ?? + ((ms: number, fn: () => void): (() => void) => { + const timers = globalThis as unknown as { + setTimeout(cb: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; + }; + const handle = timers.setTimeout(fn, ms); + return () => timers.clearTimeout(handle); + }); + const report = (error: unknown): void => reportGuarded(options.onError, error); // Per PHYSICAL display (its key); `id` is the number it has now, refreshed every tick. const rings = new Map(); let handle: unknown; @@ -242,11 +265,20 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi ): Promise> => { const grabbed = await Promise.all( layers.map(async (layer) => { + let cancel: (() => void) | undefined; try { - return { bytes: await layer.capture(), bounds: layer.bounds }; + // A window that never answers must not hold the whole display (and, through the tick, every display) hostage. + const timeout = new Promise((_, reject) => { + cancel = after(layerTimeoutMs, () => + reject(new Error(`window capture did not answer in ${layerTimeoutMs} ms`)), + ); + }); + return { bytes: await Promise.race([layer.capture(), timeout]), bounds: layer.bounds }; } catch (error) { - options.onError?.(error); + report(error); return undefined; // one window failing does not cost the display its frame + } finally { + cancel?.(); } }), ); @@ -255,6 +287,11 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi const frameOf = async (display: DisplayInfo, started: number, mine: number): Promise => { try { + // Refreshed now, even when this display shows no window: its ring must not keep a number another display holds. + const known = rings.get(display.key ?? display.id); + if (known !== undefined) { + known.id = display.id; + } const layers = await grab(options.layers(display)); if (layers.length === 0) { return; @@ -272,7 +309,7 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi ring.frames.shift(); } } catch (error) { - options.onError?.(error); + report(error); } }; @@ -297,7 +334,10 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi // A display that is gone keeps its frames only as long as the ring covers: after that nothing of it is in the window. const cutoff = options.now() - (maxFrames / fps) * 1000; for (const [key, ring] of rings) { - if ((ring.frames[ring.frames.length - 1]?.timestamp ?? 0) < cutoff) { + while (ring.frames.length > 0 && (ring.frames[0] as VideoFrame).timestamp < cutoff) { + ring.frames.shift(); + } + if (ring.frames.length === 0) { rings.delete(key); } } @@ -307,7 +347,7 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi } } } catch (error) { - options.onError?.(error); // a screen that is not ready, a throwing clock: the timer must never see a rejection + report(error); // a screen that is not ready, a throwing clock or sink: the timer must never see a rejection } }; @@ -337,7 +377,7 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi entry.lane = String(id); out.push(entry); } catch (error) { - options.onError?.(error); // one display's muxer failing costs only that display's video + report(error); // one display's muxer failing costs only that display's video } } return out; diff --git a/packages/node/src/launch-recovery-encoders.test.ts b/packages/node/src/launch-recovery-encoders.test.ts new file mode 100644 index 00000000..55245e14 --- /dev/null +++ b/packages/node/src/launch-recovery-encoders.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { FileStreamEncoder, LaunchRecoveryOptions } from '@bugsee/core'; +import { runLaunchRecovery } from '@bugsee/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { SystemProbe } from './environment'; +import { type BugseeLaunchOptions, launch, type NodeRuntime } from './launch'; +import { recoverInstances } from './recover-instances'; + +// A recovered bundle is assembled with the context `launch()` hands the dead-sibling scan. It must carry the stream encoders +// of the live export, or a recovered multi-window replay is merged into one member (a lane is split only when an encoder +// exists for the type). +vi.mock('@bugsee/core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runLaunchRecovery: vi.fn(actual.runLaunchRecovery) }; +}); +vi.mock('./recover-instances', () => ({ recoverInstances: vi.fn(async () => {}) })); + +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', +}; +const fakeProcess = (): NodeRuntime => + ({ + on() { + return this; + }, + off() { + return this; + }, + listeners: () => [], + stderr: { write: () => {} }, + exit: () => {}, + kill: () => {}, + pid: process.pid, + }) as unknown as NodeRuntime; + +const clients: ReturnType[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop(0))); + vi.mocked(runLaunchRecovery).mockClear(); + vi.mocked(recoverInstances).mockClear(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +const options = (over: Partial = {}): BugseeLaunchOptions => ({ + process: fakeProcess(), + transport: async () => ({ status: 200, headers: {}, body: new Uint8Array() }), + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + detectCrashes: false, + dataDir: mkdtempSync(join(tmpdir(), 'bugsee-recovery-enc-')), + ...over, +}); + +const recoveryContext = async (over: Partial) => { + clients.push(launch('tok', options(over))); + const args = vi.mocked(runLaunchRecovery).mock.calls[0]?.[0] as LaunchRecoveryOptions; + await args.scan?.(); + const scanOptions = vi.mocked(recoverInstances).mock.calls[0]?.[0]; + return scanOptions?.context(); +}; + +describe('launch — the recovery context carries the stream encoders', () => { + it('hands the replay stream encoder to the dead-sibling scan', async () => { + const replay: FileStreamEncoder = { begin: () => ({ write: () => [], end: () => [] }) }; + const context = await recoveryContext({ streamEncoders: { replay } }); + expect(context?.streamEncoders?.replay).toBe(replay); + }); + + it('adds none when the host gave none', async () => { + const context = await recoveryContext({}); + expect(context).not.toHaveProperty('streamEncoders'); + }); +}); diff --git a/packages/node/src/launch.test.ts b/packages/node/src/launch.test.ts index 84449cf9..0efe9076 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -2775,6 +2775,39 @@ describe('launch — forwarded reportSnapshots + fileEncoders (extension seam)', }); }); +describe('launch — forwarded streamEncoders (one member per visual source lane)', () => { + it('builds one replay member per window lane through the forwarded stream encoder', async () => { + const encoder = { + begin: () => { + let first = true; + return { + write: (entry: { lane?: string }) => { + const chunk = new TextEncoder().encode(`lane:${entry.lane}`); + if (first) { + first = false; + return [chunk]; + } + return []; + }, + end: () => [], + }; + }, + }; + const store = memStore(); + store.add({ type: 'replay', timestamp: Date.now(), serialized: '{"a":1}', lane: '3' }); + store.add({ type: 'replay', timestamp: Date.now(), serialized: '{"b":2}', lane: '8' }); + const { fn: transport, puts } = recordingTransport(); + const client = launchTracked( + 'tok', + baseOptions({ transport, captureStore: store, streamEncoders: { replay: encoder } }), + ); + await client.logException(new Error('boom')); + const files = unzipSync(puts[0] as Uint8Array); + expect(new TextDecoder().decode(files['replay.bin'])).toBe('lane:3'); + expect(new TextDecoder().decode(files['replay.1.bin'])).toBe('lane:8'); + }); +}); + // launchCore() is the seam the umbrella uses: it returns the same public client launch() returns, PLUS the // internal wiring the umbrella needs to wire performance/OTel. launch() = launchCore(...).client. describe('launchCore', () => { diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 83a87ffc..910b1c5a 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -504,6 +504,12 @@ export interface BugseeLaunchOptions { * `video` file). A platform extension registers its encoder here; JSON file types need no encoder. */ fileEncoders?: BundleAssemblyContext['fileEncoders']; + /** + * Per-file-type STREAMING encoders (the replay's `replay.bin`). A type that has one is split into one member per visual source + * lane (a window of the Electron main), and a recovered bundle uses the same encoders. Without one the type's records are + * written as they are, merged. + */ + streamEncoders?: BundleAssemblyContext['streamEncoders']; /** * Auto-instrument INCOMING HTTP servers (the node:http emit patch + any injected native serve wraps) for @@ -1004,6 +1010,7 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc : {}), ...(reportSnapshots.length > 0 ? { reportSnapshots } : {}), ...(options.fileEncoders !== undefined ? { fileEncoders: options.fileEncoders } : {}), + ...(options.streamEncoders !== undefined ? { streamEncoders: options.streamEncoders } : {}), // With an on-disk instance the report's zip is built straight into a temp file under it (and streamed from there to // the durable queue and the upload), so a large report never sits in the heap; without one, memory as before. ...(instanceLayout !== undefined @@ -1231,7 +1238,15 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc ownInstanceId: instanceLayout.instanceId, uploadPipeline, // a recovered incident belongs to a PRIOR process: this launch's uptime would be wrong for it, so it is omitted - context: () => ({ appToken, environment: withoutUptime(getEnvironment()), clock }), + context: () => ({ + appToken, + environment: withoutUptime(getEnvironment()), + clock, + // The same encoders as the live export: without them a recovered multi-window replay merges into one member. + ...(options.streamEncoders !== undefined + ? { streamEncoders: options.streamEncoders } + : {}), + }), ...(options.nativeCrash !== undefined ? { nativeCrashSource: options.nativeCrash.source } : {}), diff --git a/packages/replay/package.json b/packages/replay/package.json index fc6abf2e..9d2d2b05 100644 --- a/packages/replay/package.json +++ b/packages/replay/package.json @@ -15,6 +15,10 @@ ".": { "types": "./src/index.ts", "import": "./src/index.ts" + }, + "./stream-encoder": { + "types": "./src/stream-encoder.ts", + "import": "./src/stream-encoder.ts" } }, "files": [ @@ -50,6 +54,16 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./stream-encoder": { + "import": { + "types": "./dist/stream-encoder.d.ts", + "default": "./dist/stream-encoder.js" + }, + "require": { + "types": "./dist/stream-encoder.d.cts", + "default": "./dist/stream-encoder.cjs" + } } }, "access": "public" diff --git a/packages/replay/tsup.config.ts b/packages/replay/tsup.config.ts index 0fcccd55..abc610b1 100644 --- a/packages/replay/tsup.config.ts +++ b/packages/replay/tsup.config.ts @@ -4,5 +4,6 @@ import { baseConfig } from '../../tsup.config.base'; // @bugsee/replay: lazy-loaded via the `replay` launch option. rrweb + fflate are external (declared deps). export default defineConfig({ ...baseConfig, - entry: ['src/index.ts'], + // `./stream-encoder` is the export encoder alone (fflate + core types): a Node/Electron main builds replay.bin with it without loading rrweb. + entry: ['src/index.ts', 'src/stream-encoder.ts'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0dc332ac..866cace7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@bugsee/protocol': specifier: workspace:* version: link:../protocol + '@bugsee/replay': + specifier: workspace:* + version: link:../replay '@bugsee/trackers': specifier: workspace:* version: link:../trackers From f9e851fdfc22b34fdda64d4e434626055baaa329 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 22:26:35 +0500 Subject: [PATCH 28/33] test(bugsee): classify the new streamEncoders launch option as host-supplied (reset on relaunch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/bugsee/src/relaunch-options.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index d2d25133..e08c6d53 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -150,6 +150,7 @@ const RESET_OPTIONS: readonly string[] = [ 'onSpanProcessor', 'onVisualReset', // wired by the host (the Electron main) to its control channel, released with the client 'setupOtelProvider', + 'streamEncoders', // supplied by the host (the Electron main builds its replay encoder); a relaunch's host re-passes it 'bridge', 'captureBreadcrumbs', 'captureDiskSpace', From 8861841dccb54ce43a4c15dbe4d1005dea42ab11 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Sun, 1 Nov 2026 22:39:18 +0500 Subject: [PATCH 29/33] fix(core,electron,node): a relaunch keeps the host's stream encoders; display video bounds compose and can take the key window from the tracker; recovery-encoder test cleans its temp dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/bugsee/src/relaunch-options.test.ts | 1 - packages/core/src/carrier.ts | 1 + packages/core/src/relaunch.test.ts | 4 ++ packages/electron/src/display-video.test.ts | 19 ++++++++++ packages/electron/src/display-video.ts | 26 ++++++++++++- packages/electron/src/video-capture.test.ts | 37 ++++++++++++++++++- packages/electron/src/video-capture.ts | 24 +++++++----- .../node/src/launch-recovery-encoders.test.ts | 14 ++++++- 8 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index e08c6d53..d2d25133 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -150,7 +150,6 @@ const RESET_OPTIONS: readonly string[] = [ 'onSpanProcessor', 'onVisualReset', // wired by the host (the Electron main) to its control channel, released with the client 'setupOtelProvider', - 'streamEncoders', // supplied by the host (the Electron main builds its replay encoder); a relaunch's host re-passes it 'bridge', 'captureBreadcrumbs', 'captureDiskSpace', diff --git a/packages/core/src/carrier.ts b/packages/core/src/carrier.ts index 8f5eb129..0ce34290 100644 --- a/packages/core/src/carrier.ts +++ b/packages/core/src/carrier.ts @@ -261,6 +261,7 @@ export const RELAUNCH_KEPT_OPTIONS: readonly string[] = [ 'triggerPipeline', 'reportSnapshots', 'fileEncoders', + 'streamEncoders', 'hardwareExtras', 'process', 'window', diff --git a/packages/core/src/relaunch.test.ts b/packages/core/src/relaunch.test.ts index 3b9a4018..007ad7c8 100644 --- a/packages/core/src/relaunch.test.ts +++ b/packages/core/src/relaunch.test.ts @@ -119,6 +119,10 @@ describe('relaunchWith — options reset to defaults, integration seams kept', ( 'captureStore', 'bundleStore', 'onError', + // a relaunch calls the platform launch, not launchMain: an encoder the host built must survive it, or the replay + // goes back to one merged member + 'fileEncoders', + 'streamEncoders', ]), ); for (const option of ['captureLogs', 'captureNetwork', 'replay', 'maxRecordingTime']) { diff --git a/packages/electron/src/display-video.test.ts b/packages/electron/src/display-video.test.ts index ff7677dc..4a91a6ab 100644 --- a/packages/electron/src/display-video.test.ts +++ b/packages/electron/src/display-video.test.ts @@ -110,6 +110,25 @@ describe('electronDisplays', () => { expect(displays().map((d) => d.key)).toEqual([30, 70]); // window 1 now }); + it("takes the key window from the tracker's keyWindowBounds when it is given (the source the displays trace uses)", () => { + // no window is focused now and none was observed: only the tracker knows the key window is on the right display + const displays = electronDisplays(screen, windowsOf(win(1, rect(0, 10, 100, 100))), () => + rect(1100, 10, 100, 100), + ); + expect(displays().map((d) => d.key)).toEqual([70, 30]); + }); + + it('falls back to its own key-window rule when keyWindowBounds says nothing or is not a rectangle', () => { + const own = windowsOf(win(1, rect(1100, 10, 100, 100))); + expect(electronDisplays(screen, own, () => undefined)().map((d) => d.key)).toEqual([70, 30]); + expect(electronDisplays(screen, own, () => 'nope')().map((d) => d.key)).toEqual([70, 30]); + expect( + electronDisplays(screen, own, () => { + throw new Error('tracker'); + })().map((d) => d.key), + ).toEqual([70, 30]); + }); + it('without any window the lowest native id is display 0', () => { const displays = electronDisplays(screen, windowsOf()); expect(displays().map((d) => [d.id, d.bounds.x])).toEqual([ diff --git a/packages/electron/src/display-video.ts b/packages/electron/src/display-video.ts index dfa34e39..20c7915e 100644 --- a/packages/electron/src/display-video.ts +++ b/packages/electron/src/display-video.ts @@ -37,9 +37,27 @@ const intersectionArea = (a: Rect, b: Rect): number => { * (`screen.getDisplayMatching`), the others follow in ascending native id. The key window is the focused one, else the one * that was focused last, else the first created visible window, else the first created (the application tracker's rule). */ +const asRect = (value: unknown): Rect | undefined => { + const r = value as Partial> | null | undefined; + return typeof r === 'object' && + r !== null && + typeof r.x === 'number' && + typeof r.y === 'number' && + typeof r.width === 'number' && + typeof r.height === 'number' + ? (r as Rect) + : undefined; +}; + +/** + * `keyWindowBounds` is the application tracker's key window (the source the `displays` trace reads on every focus event); give + * it when there is one, so lane 0 and trace id 0 cannot disagree. Without it (or when it says nothing) the adapter applies the + * same rule over the windows it sees, remembering the last focused one between ticks. + */ export function electronDisplays( screen: ScreenLike, BrowserWindow: ElectronBrowserWindowLike, + keyWindowBounds?: () => unknown, ): () => DisplayInfo[] { let lastFocused: WindowLike | undefined; const usable = (window: WindowLike): boolean => window.isDestroyed?.() !== true; @@ -56,7 +74,13 @@ export function electronDisplays( (lastFocused !== undefined && usable(lastFocused) ? lastFocused : undefined) ?? created.find((window) => window.isVisible()) ?? created[0]; - const bounds = key?.getBounds(); + let tracked: Rect | undefined; + try { + tracked = asRect(keyWindowBounds?.()); + } catch { + tracked = undefined; // a throwing tracker: use our own rule + } + const bounds = tracked ?? key?.getBounds(); const areas = native.map((display) => bounds === undefined ? 0 : intersectionArea(display.bounds, bounds), ); diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index 5275d37f..d0106c36 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -695,7 +695,40 @@ describe('createDisplayVideoSource — one composed frame per display', () => { expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['0', '1']); }); - it('cancels the capture timer of a window that answered in time', async () => { + it('a compose that never answers is abandoned after stuckMs: that display loses the frame, the other one records', async () => { + const errors: unknown[] = []; + const s = fakeScheduler(); + const timers: Array<{ ms: number; fire: () => void }> = []; + const source = createDisplayVideoSource({ + displays: () => [ + { id: 0, bounds: rect(0, 0, 10, 10) }, + { id: 1, bounds: rect(10, 0, 10, 10) }, + ], + layers: (display) => [ + { bounds: display.bounds, capture: async () => new Uint8Array([display.id]) }, + ], + compose: (display) => + display.id === 0 ? new Promise(() => {}) : Promise.resolve(new Uint8Array([5])), + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => 1000, + stuckMs: 4000, + after: (ms, fn) => { + timers.push({ ms, fire: fn }); + return () => {}; + }, + onError: (e) => errors.push(e), + }); + source.start(); + const ticking = s.tick(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + for (const t of timers.filter((x) => x.ms === 4000)) t.fire(); // the compose timers (the capture timers are layerTimeoutMs) + await ticking; + expect(errors).toHaveLength(1); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['1']); + }); + + it('cancels the capture timer and the compose timer of work that answered in time', async () => { const s = fakeScheduler(); const cancelled: number[] = []; let n = 0; @@ -713,7 +746,7 @@ describe('createDisplayVideoSource — one composed frame per display', () => { }); source.start(); await s.tick(); - expect(cancelled).toEqual([0]); + expect(cancelled).toEqual([0, 1]); // the window capture's timer, then the compose's }); it('a display whose last window left gives up the id another display now holds', async () => { diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index 52df211c..d92cbc71 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -252,6 +252,14 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi return () => timers.clearTimeout(handle); }); const report = (error: unknown): void => reportGuarded(options.onError, error); + // `work` settles with its own result, or the call fails once `ms` have passed (the work itself cannot be cancelled). + const bounded = (work: Promise, ms: number, what: string): Promise => { + let cancel: (() => void) | undefined; + const timeout = new Promise((_, reject) => { + cancel = after(ms, () => reject(new Error(`${what} did not answer in ${ms} ms`))); + }); + return Promise.race([work, timeout]).finally(() => cancel?.()); + }; // Per PHYSICAL display (its key); `id` is the number it has now, refreshed every tick. const rings = new Map(); let handle: unknown; @@ -265,20 +273,15 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi ): Promise> => { const grabbed = await Promise.all( layers.map(async (layer) => { - let cancel: (() => void) | undefined; try { // A window that never answers must not hold the whole display (and, through the tick, every display) hostage. - const timeout = new Promise((_, reject) => { - cancel = after(layerTimeoutMs, () => - reject(new Error(`window capture did not answer in ${layerTimeoutMs} ms`)), - ); - }); - return { bytes: await Promise.race([layer.capture(), timeout]), bounds: layer.bounds }; + return { + bytes: await bounded(layer.capture(), layerTimeoutMs, 'window capture'), + bounds: layer.bounds, + }; } catch (error) { report(error); return undefined; // one window failing does not cost the display its frame - } finally { - cancel?.(); } }), ); @@ -296,7 +299,8 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi if (layers.length === 0) { return; } - const bytes = await options.compose(display, layers); + // A compositor that never settles must not keep the layers (and this display's turn) for ever either. + const bytes = await bounded(options.compose(display, layers), stuckMs, 'compose'); if (started !== epoch || mine !== token) { return; // cleared (blackout) or abandoned while this frame was in flight } diff --git a/packages/node/src/launch-recovery-encoders.test.ts b/packages/node/src/launch-recovery-encoders.test.ts index 55245e14..0509d160 100644 --- a/packages/node/src/launch-recovery-encoders.test.ts +++ b/packages/node/src/launch-recovery-encoders.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { FileStreamEncoder, LaunchRecoveryOptions } from '@bugsee/core'; @@ -47,13 +47,23 @@ const fakeProcess = (): NodeRuntime => }) as unknown as NodeRuntime; const clients: ReturnType[] = []; +const dirs: string[] = []; afterEach(async () => { await Promise.all(clients.splice(0).map((c) => c.stop(0))); + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } vi.mocked(runLaunchRecovery).mockClear(); vi.mocked(recoverInstances).mockClear(); delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; }); +const tempDir = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'bugsee-recovery-enc-')); + dirs.push(dir); + return dir; +}; + const options = (over: Partial = {}): BugseeLaunchOptions => ({ process: fakeProcess(), transport: async () => ({ status: 200, headers: {}, body: new Uint8Array() }), @@ -61,7 +71,7 @@ const options = (over: Partial = {}): BugseeLaunchOptions = systemMetricsSampler: () => [], captureNetwork: false, detectCrashes: false, - dataDir: mkdtempSync(join(tmpdir(), 'bugsee-recovery-enc-')), + dataDir: tempDir(), ...over, }); From 286172d924c2e7714a3b67fd7e666b676bcf24f9 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 15:36:48 +0500 Subject: [PATCH 30/33] feat(electron): launchMain hands a video.source factory the tracker's key window so display video and the displays trace agree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/electron/src/launch-main.test.ts | 45 +++++++++++++++++++++++ packages/electron/src/launch-main.ts | 19 ++++++++-- packages/electron/src/main.ts | 7 +++- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index e45e9c5f..b2aed762 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -1865,6 +1865,51 @@ describe('launchMain — Electron domain trackers: power, display, application w expect(app.table.get('browser-window-created')).toBeUndefined(); // the events mux arms none }); + it("a `video.source` factory is called once with the launch tracker's key window, so video display 0 follows the same source as the displays trace", () => { + clear(); + const app = new FakeEmitter(); + const screen = Object.assign(new FakeEmitter(), { getAllDisplays: () => [] }); + const bounds = { x: 1, y: 2, width: 3, height: 4 }; + const BrowserWindow = { + getAllWindows: () => [], + getFocusedWindow: () => ({ isDestroyed: () => false, getBounds: () => bounds }), + }; + const source: VideoCaptureSource = { + start() {}, + stop() {}, + clear() {}, + snapshot: async () => [], + }; + const factory = vi.fn((_env: { keyWindowBounds: () => unknown }) => source); + launchMain('tok', { + ipcMain: fakeIpcMain().ipcMain, + launch: fakeLaunch().launch, + app, + screen, + BrowserWindow, + video: { source: factory }, + }); + expect(factory).toHaveBeenCalledOnce(); + expect(factory.mock.calls[0]?.[0].keyWindowBounds()).toEqual(bounds); + }); + + it('a `video.source` factory still gets a keyWindowBounds that answers undefined when no window tracker exists', () => { + clear(); + const factory = vi.fn((_env: { keyWindowBounds: () => unknown }) => ({ + start() {}, + stop() {}, + clear() {}, + snapshot: async () => [], + })); + launchMain('tok', { + ipcMain: fakeIpcMain().ipcMain, + launch: fakeLaunch().launch, + video: { source: factory }, + }); + expect(factory).toHaveBeenCalledOnce(); + expect(factory.mock.calls[0]?.[0].keyWindowBounds()).toBeUndefined(); + }); + it.each([ null, 0, diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 4c2f8419..5c241aec 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -130,13 +130,23 @@ import type { VideoCaptureSource } from './video-capture'; /** The node `launchCore` shape, injectable for tests. */ type NodeLaunch = typeof launchCore; +/** What launch hands a `video.source` factory. */ +export interface VideoSourceEnv { + /** The key window's bounds (a rectangle), or undefined when there is none. Never throws. */ + keyWindowBounds(): unknown; +} + /** Opt-in pixel-capture video. The app supplies a source built with its Electron/DOM specifics (e.g. * `createCapturePageVideoSource` over `webContents.capturePage`, `createDisplayVideoSource` (one composed video per display), * and/or a `MediaRecorder` source — they can * be composed into one source). rrweb DOM-replay stays the DEFAULT and rides the renderer streaming path. */ export interface VideoLaunchOptions { - /** The pixel-video capture source. */ - source: VideoCaptureSource; + /** + * The pixel-video capture source, or a factory that builds it. A factory is called once, during launch, with the + * launch tracker's key window (`keyWindowBounds`, the same source the `displays` trace uses): pass it on as + * `electronDisplays(screen, BrowserWindow, env.keyWindowBounds)` so video display 0 and trace display 0 agree. + */ + source: VideoCaptureSource | ((env: VideoSourceEnv) => VideoCaptureSource); /** macOS Screen-Recording (TCC) permission gate — resolves false → inert. Default granted. */ hasPermission?: () => boolean | Promise; } @@ -551,7 +561,10 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee // controller follows the blackout: frames stop, the ring is cleared, and snapshots are empty for the span. if (video !== undefined) { videoController = createPixelVideoController({ - source: video.source, + source: + typeof video.source === 'function' + ? video.source({ keyWindowBounds: () => appTracker?.keyWindowBounds() }) + : video.source, blackout, ...(video.hasPermission !== undefined ? { hasPermission: video.hasPermission } : {}), ...(nodeOptions.onError !== undefined ? { onError: nodeOptions.onError } : {}), diff --git a/packages/electron/src/main.ts b/packages/electron/src/main.ts index 83804946..c3fec233 100644 --- a/packages/electron/src/main.ts +++ b/packages/electron/src/main.ts @@ -29,7 +29,12 @@ export { type ScreenLike, type WindowLike, } from './display-video'; -export { type LaunchMainOptions, launchMain, type VideoLaunchOptions } from './launch-main'; +export { + type LaunchMainOptions, + launchMain, + type VideoLaunchOptions, + type VideoSourceEnv, +} from './launch-main'; export { type ControlSenderLike, createElectronMainControl, From 9251a5c8a54007e5a38765d7dcaa88d6f8c966e4 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 15:47:21 +0500 Subject: [PATCH 31/33] fix(electron): a throwing video.source factory is reported and launch continues without video, so the receiver and control channel still start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/electron/src/launch-main.test.ts | 24 +++++++++++++++++++++++ packages/electron/src/launch-main.ts | 17 +++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/electron/src/launch-main.test.ts b/packages/electron/src/launch-main.test.ts index b2aed762..61f0db15 100644 --- a/packages/electron/src/launch-main.test.ts +++ b/packages/electron/src/launch-main.test.ts @@ -1893,6 +1893,30 @@ describe('launchMain — Electron domain trackers: power, display, application w expect(factory.mock.calls[0]?.[0].keyWindowBounds()).toEqual(bounds); }); + it('a throwing `video.source` factory is reported and launch carries on WITHOUT video: the receiver is still started', async () => { + clear(); + const ipc = fakeIpcMain(); + const f = fakeLaunch(); + const onError = vi.fn(); + const boom = new RangeError('fps must be a positive finite number, got 0'); + const client = launchMain('tok', { + ipcMain: ipc.ipcMain, + launch: f.launch, + onError, + video: { + source: () => { + throw boom; + }, + }, + }); + expect(onError).toHaveBeenCalledWith(boom); + expect(ipc.has(BUGSEE_STREAM_CHANNEL)).toBe(true); // the rest of the Electron wiring still ran + const opts = f.received?.options as BugseeLaunchOptions; + expect(await opts.reportSnapshots?.[0]?.(5)).toEqual([]); // no video, and no crash at report time + await client.stop(); + expect(ipc.has(BUGSEE_STREAM_CHANNEL)).toBe(false); // the wrapped stop still removes the listeners + }); + it('a `video.source` factory still gets a keyWindowBounds that answers undefined when no window tracker exists', () => { clear(); const factory = vi.fn((_env: { keyWindowBounds: () => unknown }) => ({ diff --git a/packages/electron/src/launch-main.ts b/packages/electron/src/launch-main.ts index 5c241aec..b5e90061 100644 --- a/packages/electron/src/launch-main.ts +++ b/packages/electron/src/launch-main.ts @@ -559,12 +559,23 @@ export function launchMain(appToken: string, options: LaunchMainOptions): Bugsee // Start pixel capture (async permission check; a report before it's active just carries no video). The // controller follows the blackout: frames stop, the ring is cleared, and snapshots are empty for the span. + // A host factory runs HERE, after the client is registered: a throw must not abort the Electron wiring below (receiver, + // control, crash reporter, wrapped stop), and a retry cannot repair it (a second launch sees a repeat). So it is reported + // and the launch simply has no pixel video. + let videoSource: VideoCaptureSource | undefined; if (video !== undefined) { - videoController = createPixelVideoController({ - source: + try { + videoSource = typeof video.source === 'function' ? video.source({ keyWindowBounds: () => appTracker?.keyWindowBounds() }) - : video.source, + : video.source; + } catch (error) { + reportGuarded(nodeOptions.onError, error); + } + } + if (video !== undefined && videoSource !== undefined) { + videoController = createPixelVideoController({ + source: videoSource, blackout, ...(video.hasPermission !== undefined ? { hasPermission: video.hasPermission } : {}), ...(nodeOptions.onError !== undefined ? { onError: nodeOptions.onError } : {}), From 5a94293ff6563dee9243dd13057acb8dbdfee3ed Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 16:01:02 +0500 Subject: [PATCH 32/33] fix(electron): a failed window keeps an opaque placeholder in the display frame; compose does not pile up behind a hung compositor; timer options are range-checked; a given key-window source is authoritative (primary display 0 when none) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../src/display-video.integration.test.ts | 2 +- packages/electron/src/display-video.test.ts | 25 +++++- packages/electron/src/display-video.ts | 40 ++++++--- packages/electron/src/video-capture.test.ts | 85 +++++++++++++++++-- packages/electron/src/video-capture.ts | 44 +++++++--- 5 files changed, 165 insertions(+), 31 deletions(-) diff --git a/packages/electron/src/display-video.integration.test.ts b/packages/electron/src/display-video.integration.test.ts index df893f07..709d1cd1 100644 --- a/packages/electron/src/display-video.integration.test.ts +++ b/packages/electron/src/display-video.integration.test.ts @@ -39,7 +39,7 @@ describe('a two-display session, end to end', () => { layers: electronWindowLayers(BrowserWindow), // "compose": the first layer's bytes stand for the composed frame of the display compose: async (display, layers) => - new Uint8Array([display.id, ...layers.map((l) => l.bytes[0] as number)]), + new Uint8Array([display.id, ...layers.map((l) => l.bytes?.[0] as number)]), encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), scheduler: { setInterval: (cb) => { diff --git a/packages/electron/src/display-video.test.ts b/packages/electron/src/display-video.test.ts index 4a91a6ab..f7f08040 100644 --- a/packages/electron/src/display-video.test.ts +++ b/packages/electron/src/display-video.test.ts @@ -118,10 +118,29 @@ describe('electronDisplays', () => { expect(displays().map((d) => d.key)).toEqual([70, 30]); }); - it('falls back to its own key-window rule when keyWindowBounds says nothing or is not a rectangle', () => { + it('a given keyWindowBounds that names no window means NO key window: the primary display is 0, as in the displays trace', () => { + // a focused window on the non-primary display is ignored: the displays trace (given the same source) has none either + const own = windowsOf(win(1, rect(1100, 10, 100, 100))); + const withPrimary = { ...screen, getPrimaryDisplay: () => ({ id: 70 }) }; + expect(electronDisplays(withPrimary, own, () => undefined)().map((d) => d.key)).toEqual([ + 70, 30, + ]); + expect(electronDisplays(withPrimary, own, () => 'nope')().map((d) => d.key)).toEqual([70, 30]); + // without a usable primary the lowest native id is 0, as the tracker does + expect(electronDisplays(screen, own, () => undefined)().map((d) => d.key)).toEqual([30, 70]); + const throwingPrimary = { + ...screen, + getPrimaryDisplay: () => { + throw new Error('primary'); + }, + }; + expect(electronDisplays(throwingPrimary, own, () => undefined)().map((d) => d.key)).toEqual([ + 30, 70, + ]); + }); + + it('falls back to its own key-window rule only when the given keyWindowBounds THROWS', () => { const own = windowsOf(win(1, rect(1100, 10, 100, 100))); - expect(electronDisplays(screen, own, () => undefined)().map((d) => d.key)).toEqual([70, 30]); - expect(electronDisplays(screen, own, () => 'nope')().map((d) => d.key)).toEqual([70, 30]); expect( electronDisplays(screen, own, () => { throw new Error('tracker'); diff --git a/packages/electron/src/display-video.ts b/packages/electron/src/display-video.ts index 20c7915e..18e75fa9 100644 --- a/packages/electron/src/display-video.ts +++ b/packages/electron/src/display-video.ts @@ -6,6 +6,8 @@ import type { DisplayInfo, Rect, WindowLayer } from './video-capture'; /** The slice of Electron's `screen` used. */ export interface ScreenLike { getAllDisplays(): Array<{ id: number; bounds: Rect }>; + /** The primary display: display 0 when there is no key window (as in the `displays` trace). */ + getPrimaryDisplay?(): { id?: unknown } | null | undefined; } /** The slice of an Electron `BrowserWindow` used. */ @@ -50,9 +52,11 @@ const asRect = (value: unknown): Rect | undefined => { }; /** - * `keyWindowBounds` is the application tracker's key window (the source the `displays` trace reads on every focus event); give - * it when there is one, so lane 0 and trace id 0 cannot disagree. Without it (or when it says nothing) the adapter applies the - * same rule over the windows it sees, remembering the last focused one between ticks. + * `keyWindowBounds` is the application tracker's key window (the source the `displays` trace reads on every focus event). When + * it is GIVEN it is authoritative, so lane 0 and trace id 0 cannot disagree: a rectangle names the key window, anything else + * means there is none and the primary display is 0 (else the lowest native id), exactly as the trace numbers it. Only a source + * that throws, or none at all, makes the adapter apply the same rule over the windows it sees, remembering the last focused + * one between ticks. */ export function electronDisplays( screen: ScreenLike, @@ -74,17 +78,33 @@ export function electronDisplays( (lastFocused !== undefined && usable(lastFocused) ? lastFocused : undefined) ?? created.find((window) => window.isVisible()) ?? created[0]; - let tracked: Rect | undefined; - try { - tracked = asRect(keyWindowBounds?.()); - } catch { - tracked = undefined; // a throwing tracker: use our own rule + let bounds: Rect | undefined; + if (keyWindowBounds === undefined) { + bounds = key?.getBounds(); + } else { + try { + bounds = asRect(keyWindowBounds()); + } catch { + bounds = key?.getBounds(); // a throwing tracker: use our own rule + } } - const bounds = tracked ?? key?.getBounds(); const areas = native.map((display) => bounds === undefined ? 0 : intersectionArea(display.bounds, bounds), ); - const keyIndex = Math.max(0, areas.indexOf(Math.max(...areas))); + let keyIndex = Math.max(0, areas.indexOf(Math.max(...areas))); + if (bounds === undefined) { + // No key window: the primary display is 0 (the trace's rule), else the lowest native id. + let primary: unknown; + try { + primary = screen.getPrimaryDisplay?.()?.id; + } catch { + primary = undefined; + } + keyIndex = Math.max( + 0, + native.findIndex((display) => display.id === primary), + ); + } const ordered = [ ...native.slice(keyIndex, keyIndex + 1), ...native.filter((_, i) => i !== keyIndex), diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index d0106c36..8a75d112 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -368,8 +368,10 @@ describe('createDisplayVideoSource — one composed frame per display', () => { [1, [{ bounds: rect(100, 0, 100, 100), capture: async () => new Uint8Array([0xc]) }]], ]); const compose = vi.fn( - async (display: { id: number }, layers: ReadonlyArray<{ bytes: Uint8Array }>) => - new Uint8Array([display.id, ...layers.map((l) => l.bytes[0] as number)]), + async ( + display: { id: number }, + layers: ReadonlyArray<{ bytes: Uint8Array | undefined; bounds: unknown }>, + ) => new Uint8Array([display.id, ...layers.map((l) => l.bytes?.[0] as number)]), ); const encode = vi.fn((frames: readonly { bytes: Uint8Array }[], fps: number) => Uint8Array.from([fps, ...frames.flatMap((f) => [...f.bytes])]), @@ -391,7 +393,7 @@ describe('createDisplayVideoSource — one composed frame per display', () => { source.start(); await s.tick(); expect(compose).toHaveBeenCalledTimes(2); - expect((compose.mock.calls[0]?.[1] ?? []).map((l) => l.bytes[0])).toEqual([0xa, 0xb]); // z order kept + expect((compose.mock.calls[0]?.[1] ?? []).map((l) => l.bytes?.[0])).toEqual([0xa, 0xb]); // z order kept const entries = await source.snapshot(5); expect(entries.map((e) => [e.type, e.lane, [...(e.data as Uint8Array)]])).toEqual([ ['video', '0', [1, 0, 0xa, 0xb]], @@ -429,10 +431,81 @@ describe('createDisplayVideoSource — one composed frame per display', () => { ]); source.start(); await s.tick(); - expect((compose.mock.calls[0]?.[1] ?? []).map((l) => l.bytes[0])).toEqual([0xb]); + // the failed window keeps its place in the z-order as a bytes-less placeholder, so the window behind it cannot show through + expect((compose.mock.calls[0]?.[1] ?? []).map((l) => [l.bytes?.[0], l.bounds])).toEqual([ + [undefined, rect(0, 0, 10, 10)], + [0xb, rect(0, 0, 10, 10)], + ]); expect(errors).toHaveLength(1); }); + it('records no frame for a display whose every window failed to grab (nothing real to show)', async () => { + const { s, source, compose, layersOf } = setup({ onError: () => {} }); + layersOf.set(0, [ + { + bounds: rect(0, 0, 10, 10), + capture: async () => { + throw new Error('grab failed'); + }, + }, + ]); + source.start(); + await s.tick(); + expect(compose.mock.calls.map((c) => (c[0] as { id: number }).id)).toEqual([1]); + expect((await source.snapshot(5)).map((e) => e.lane)).toEqual(['1']); + }); + + it.each([ + ['stuckMs', { stuckMs: 0 }], + ['stuckMs', { stuckMs: Number.NaN }], + ['stuckMs', { stuckMs: 2_147_483_648 }], + ['layerTimeoutMs', { layerTimeoutMs: 0 }], + ['layerTimeoutMs', { layerTimeoutMs: Number.POSITIVE_INFINITY }], + ['layerTimeoutMs', { stuckMs: 1 }], // the derived default (floor(1 / 2) = 0) is checked too + ])('rejects a %s outside the timer range, as fps is', (name, over) => { + expect(() => setup(over)).toThrow(new RegExp(`${name} must be`)); + }); + + it('does not start another compose for a display while its earlier compose has not settled, and resumes once it does', async () => { + let time = 1000; + const s = fakeScheduler(); + let release: ((bytes: Uint8Array) => void) | undefined; + let calls = 0; + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => [{ bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([1]) }], + compose: () => { + calls += 1; + return new Promise((resolve) => { + release = resolve; + }); + }, + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => time, + stuckMs: 100, + after: (ms, fn) => { + const handle = globalThis.setTimeout(fn, 0); + void ms; + return () => globalThis.clearTimeout(handle); + }, + onError: () => {}, + }); + source.start(); + await s.tick(); // compose #1 starts, times out (stuckMs) and is abandoned, still pending + time += 1000; + await s.tick(); + time += 1000; + await s.tick(); + expect(calls).toBe(1); // no pile-up behind a hung compositor + release?.(new Uint8Array([9])); + await Promise.resolve(); + await Promise.resolve(); + time += 1000; + await s.tick(); + expect(calls).toBe(2); // it settled, so the display is served again + }); + it('a display whose compose fails loses that frame only, and the other display still records', async () => { const errors: unknown[] = []; const base = setup(); @@ -667,7 +740,7 @@ describe('createDisplayVideoSource — one composed frame per display', () => { ] : [{ bounds: rect(10, 0, 10, 10), capture: async () => new Uint8Array([4]) }], compose: async (display, layers) => { - composed.push([display.id, ...layers.map((l) => l.bytes[0] as number)]); + composed.push([display.id, ...layers.map((l) => l.bytes?.[0] ?? -1)]); return new Uint8Array([display.id]); }, encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), @@ -688,7 +761,7 @@ describe('createDisplayVideoSource — one composed frame per display', () => { for (const fire of timers) fire(); // the hung window's timer fires await ticking; expect(composed.sort()).toEqual([ - [0, 9], + [0, -1, 9], [1, 4], ]); expect(errors).toHaveLength(1); diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index d92cbc71..0d122e06 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -199,10 +199,12 @@ export interface DisplayVideoSourceOptions { /** * Paint the windows onto one frame the size of the display and encode it (real: an offscreen canvas or an image * library; an injected seam, like `encode`). Layers arrive back to front with their grabbed bytes and bounds. + * A window whose grab failed or timed out arrives with `bytes: undefined`, in its place in the order: paint an opaque + * placeholder over its bounds, so the window behind it does not show through where it was. */ compose: ( display: DisplayInfo, - layers: ReadonlyArray<{ bytes: Uint8Array; bounds: Rect }>, + layers: ReadonlyArray<{ bytes: Uint8Array | undefined; bounds: Rect }>, ) => Promise; /** Mux one display's frame ring into the container bytes (as for {@link CapturePageVideoSourceOptions}). */ encode: (frames: readonly VideoFrame[], fps: number) => Uint8Array; @@ -213,12 +215,12 @@ export interface DisplayVideoSourceOptions { maxFrames?: number; /** * A tick that has not finished after this many ms (a `capturePage` of an occluded or hung window never answers) is - * abandoned, so capture resumes at the next tick; its late result is discarded. Default 10000. + * abandoned, so capture resumes at the next tick; its late result is discarded. 1 to 2147483647. Default 10000. */ stuckMs?: number; /** * One window's `capture()` that has not answered after this many ms is dropped from its display's frame (the other windows - * still compose, and the other displays are not held up). Default half of `stuckMs`. + * still compose, and the other displays are not held up). 1 to 2147483647. Default half of `stuckMs`. */ layerTimeoutMs?: number; /** One-shot timer: calls `fn` after `ms` and returns its canceller. Default the global `setTimeout`. */ @@ -241,6 +243,15 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi const maxFrames = options.maxFrames ?? 300; const stuckMs = options.stuckMs ?? 10_000; const layerTimeoutMs = options.layerTimeoutMs ?? Math.floor(stuckMs / 2); + for (const [name, ms] of [ + ['stuckMs', stuckMs], + ['layerTimeoutMs', layerTimeoutMs], + ] as const) { + // The default timer runs a delay outside 1..2147483647 ms as 1 ms, which would abandon every grab at once. + if (!(ms >= 1 && ms <= 2_147_483_647)) { + throw new RangeError(`${name} must be from 1 to 2147483647 ms, got ${ms}`); + } + } const after = options.after ?? ((ms: number, fn: () => void): (() => void) => { @@ -264,13 +275,14 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi const rings = new Map(); let handle: unknown; let epoch = 0; // bumped by clear(): a frame that began before it is discarded + const composing = new Set(); // displays whose compose call has been started and has not yet settled let busy = false; // a slow compose must not overlap the next tick let busySince = 0; let token = 0; // identifies the running tick: an abandoned one is told apart from the current one const grab = async ( layers: readonly WindowLayer[], - ): Promise> => { + ): Promise> => { const grabbed = await Promise.all( layers.map(async (layer) => { try { @@ -281,11 +293,12 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi }; } catch (error) { report(error); - return undefined; // one window failing does not cost the display its frame + // One window failing does not cost the display its frame, but it keeps its place as a placeholder. + return { bytes: undefined, bounds: layer.bounds }; } }), ); - return grabbed.filter((layer) => layer !== undefined); + return grabbed; }; const frameOf = async (display: DisplayInfo, started: number, mine: number): Promise => { @@ -296,15 +309,24 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi known.id = display.id; } const layers = await grab(options.layers(display)); - if (layers.length === 0) { - return; + if (!layers.some((layer) => layer.bytes !== undefined)) { + return; // no window, or none that could be grabbed: nothing real to show } - // A compositor that never settles must not keep the layers (and this display's turn) for ever either. - const bytes = await bounded(options.compose(display, layers), stuckMs, 'compose'); + const key = display.key ?? display.id; + if (composing.has(key)) { + return; // an earlier compose for this display has not settled (a hung compositor): do not pile another on it + } + const work = options.compose(display, layers); + composing.add(key); + const release = (): void => { + composing.delete(key); + }; + work.then(release, release); + // A compositor that never settles must not keep this tick for ever either. + const bytes = await bounded(work, stuckMs, 'compose'); if (started !== epoch || mine !== token) { return; // cleared (blackout) or abandoned while this frame was in flight } - const key = display.key ?? display.id; const ring = rings.get(key) ?? { id: display.id, frames: [] }; ring.id = display.id; rings.set(key, ring); From e883b262d729b0ac2c7c016dfa395f4aea55702d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 2 Nov 2026 16:10:37 +0500 Subject: [PATCH 33/33] fix(electron): skip grabbing a display's windows while its earlier compose is still pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/electron/src/video-capture.test.ts | 57 ++++++++++++++++++++- packages/electron/src/video-capture.ts | 9 +++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/electron/src/video-capture.test.ts b/packages/electron/src/video-capture.test.ts index 8a75d112..ab82b349 100644 --- a/packages/electron/src/video-capture.test.ts +++ b/packages/electron/src/video-capture.test.ts @@ -466,14 +466,67 @@ describe('createDisplayVideoSource — one composed frame per display', () => { expect(() => setup(over)).toThrow(new RegExp(`${name} must be`)); }); + it('an abandoned tick that finishes grabbing after a newer tick started a compose does not start a second one', async () => { + let time = 1000; + const s = fakeScheduler(); + let releaseGrab: ((bytes: Uint8Array) => void) | undefined; + let slow = true; + let calls = 0; + const source = createDisplayVideoSource({ + displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], + layers: () => [ + { + bounds: rect(0, 0, 10, 10), + capture: () => + slow + ? new Promise((resolve) => { + releaseGrab = resolve; + }) + : Promise.resolve(new Uint8Array([1])), + }, + ], + compose: () => { + calls += 1; + return new Promise(() => {}); // never settles + }, + encode: (frames) => Uint8Array.from(frames.flatMap((f) => [...f.bytes])), + scheduler: s.scheduler, + now: () => time, + stuckMs: 5000, + layerTimeoutMs: 100_000, + after: () => () => {}, + onError: () => {}, + }); + source.start(); + void s.tick(); // tick A: its window grab hangs + await Promise.resolve(); + time += 5000; + slow = false; + void s.tick(); // tick B abandons A, grabs at once and starts a compose that never settles + for (let i = 0; i < 10; i++) await Promise.resolve(); + expect(calls).toBe(1); + releaseGrab?.(new Uint8Array([2])); // A's grab finally answers + for (let i = 0; i < 10; i++) await Promise.resolve(); + expect(calls).toBe(1); + }); + it('does not start another compose for a display while its earlier compose has not settled, and resumes once it does', async () => { let time = 1000; const s = fakeScheduler(); let release: ((bytes: Uint8Array) => void) | undefined; let calls = 0; + let grabs = 0; const source = createDisplayVideoSource({ displays: () => [{ id: 0, bounds: rect(0, 0, 10, 10) }], - layers: () => [{ bounds: rect(0, 0, 10, 10), capture: async () => new Uint8Array([1]) }], + layers: () => [ + { + bounds: rect(0, 0, 10, 10), + capture: async () => { + grabs += 1; + return new Uint8Array([1]); + }, + }, + ], compose: () => { calls += 1; return new Promise((resolve) => { @@ -498,12 +551,14 @@ describe('createDisplayVideoSource — one composed frame per display', () => { time += 1000; await s.tick(); expect(calls).toBe(1); // no pile-up behind a hung compositor + expect(grabs).toBe(1); // and no window is grabbed (GPU readback + JPEG) for a frame that would be thrown away release?.(new Uint8Array([9])); await Promise.resolve(); await Promise.resolve(); time += 1000; await s.tick(); expect(calls).toBe(2); // it settled, so the display is served again + expect(grabs).toBe(2); }); it('a display whose compose fails loses that frame only, and the other display still records', async () => { diff --git a/packages/electron/src/video-capture.ts b/packages/electron/src/video-capture.ts index 0d122e06..0dccefd3 100644 --- a/packages/electron/src/video-capture.ts +++ b/packages/electron/src/video-capture.ts @@ -308,13 +308,18 @@ export function createDisplayVideoSource(options: DisplayVideoSourceOptions): Vi if (known !== undefined) { known.id = display.id; } + const key = display.key ?? display.id; + if (composing.has(key)) { + // An earlier compose for this display has not settled (a hung compositor): do not pile another on it, and do not + // grab (GPU readback + encode) windows for a frame that would be thrown away. + return; + } const layers = await grab(options.layers(display)); if (!layers.some((layer) => layer.bytes !== undefined)) { return; // no window, or none that could be grabbed: nothing real to show } - const key = display.key ?? display.id; if (composing.has(key)) { - return; // an earlier compose for this display has not settled (a hung compositor): do not pile another on it + return; // started by an abandoned tick while these windows were being grabbed } const work = options.compose(display, layers); composing.add(key);