diff --git a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts index 13f63a610..5273d385d 100644 --- a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts @@ -3,6 +3,7 @@ import type { ElectronApplication, Page } from '@playwright/test'; import { defaultXtreamPortalName } from '../electron-test-fixtures'; import { countJourneyMainIpcInFlight, + detachJourneyMainIpcCapture, installJourneyMainIpcCapture, JOURNEY_MAIN_IPC_STATE_KEY, JOURNEY_RENDERER_API_TRACE_CHANNEL, @@ -201,6 +202,9 @@ export async function measureOpenSourceJourney( ledger, probeOptions.stateKey ); + // J1's capture was only needed to see pending launch calls while + // settling; detached, it no longer runs for every J2 bridge call. + await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY); await card.click({ timeout: timeoutMs }); const renderer: JourneyRendererProbeState = await waitForJourneyRendererProbe( diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts index d6a7f8aa3..219bc041f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts @@ -9,6 +9,7 @@ import type { ElectronApplication } from '@playwright/test'; import { assertJourneyMainIpcCapture, countJourneyMainIpcInFlight, + detachJourneyMainIpcCapture, installJourneyMainIpcCapture, JOURNEY_RENDERER_API_TRACE_CHANNEL, peekJourneyMainIpcCapture, @@ -139,6 +140,7 @@ test('rejects captures that cannot bound the counter exactly', () => { */ function createFakeElectronApp(): { readonly app: ElectronApplication; + listeners(): number; send( senderId: number, method: string, @@ -161,6 +163,7 @@ function createFakeElectronApp(): { } as unknown as ElectronApplication; return { app, + listeners: () => ipcMain.listenerCount(channel), send: (senderId, method, args, phase = 'start') => { ipcMain.emit( channel, @@ -175,7 +178,8 @@ async function withCapture( options: Partial, run: ( fake: ReturnType, - read: () => Promise + read: () => Promise, + stateKey: string ) => Promise ): Promise { const stateKey = `__journeyIpcCaptureTest${Math.random()}`; @@ -188,9 +192,15 @@ async function withCapture( stateKey, ...options, }); - await run(fake, () => peekJourneyMainIpcCapture(fake.app, stateKey)); + await run( + fake, + () => peekJourneyMainIpcCapture(fake.app, stateKey), + stateKey + ); } finally { - delete (globalThis as unknown as Record)[stateKey]; + const target = globalThis as unknown as Record; + delete target[stateKey]; + delete target[`${stateKey}:detach`]; } } @@ -344,3 +354,21 @@ test('tracks bridge calls in flight from start to success or error', async () => assert.equal(countJourneyMainIpcInFlight(state), 0); }); }); + +test('a detached capture stops listening and keeps its last state', async () => { + await withCapture({}, async (fake, read, stateKey) => { + fake.send(1, 'getSettings', []); + assert.equal(fake.listeners(), 1); + await detachJourneyMainIpcCapture(fake.app, stateKey); + assert.equal(fake.listeners(), 0); + fake.send(1, 'getSettings', []); + fake.send(1, 'dbGetAppState', []); + const state = await read(); + assert.equal(state.callsBeforeSentinel, 1); + assert.deepEqual(state.inFlightByMethod, { getSettings: 1 }); + await assert.rejects( + detachJourneyMainIpcCapture(fake.app, stateKey), + /not-attached/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts index 4e7dbbd08..8fc3d7c7f 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts @@ -102,7 +102,10 @@ export async function installJourneyMainIpcCapture( } }; target[input.stateKey] = state; - ipcMain.on(input.channel, (event, payload: unknown) => { + const listener = ( + event: { sender: { id: number } }, + payload: unknown + ): void => { const record = typeof payload === 'object' && payload !== null ? (payload as Record) @@ -165,10 +168,36 @@ export async function installJourneyMainIpcCapture( state.callsBeforeSentinel += 1; state.callsByMethod[method] = (state.callsByMethod[method] ?? 0) + 1; - }); + }; + ipcMain.on(input.channel, listener); + // Kept next to the state (which is read as JSON) so the capture can + // be detached from the same main process later. + target[`${input.stateKey}:detach`] = () => { + ipcMain.removeListener(input.channel, listener); + }; }, options); } +/** + * Removes a capture's listener. J2 detaches J1's capture once it has used it + * to settle, so the launch listener does not run for every bridge call of + * the measured click. + */ +export async function detachJourneyMainIpcCapture( + electronApp: ElectronApplication, + stateKey: string +): Promise { + await electronApp.evaluate((_electron, key) => { + const target = globalThis as unknown as Record; + const detach = target[`${key}:detach`]; + if (typeof detach !== 'function') { + throw new Error('journey-main-ipc-capture-not-attached'); + } + (detach as () => void)(); + delete target[`${key}:detach`]; + }, stateKey); +} + /** Total of `inFlightByMethod`. */ export function countJourneyMainIpcInFlight( state: JourneyMainIpcCaptureState diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index a1c9c3329..620db7a09 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -264,7 +264,9 @@ fails the iteration). Bridge calls in flight come from J1's IPC capture: it was installed before the document loaded, and the preload follows every traced `start` with exactly one `success` or `error`, so a call that is still pending cannot resolve after the click and have its DOM changes or follow-up -calls counted as J2. The settle is a snapshot, and Playwright's +calls counted as J2. After settling, J1's capture is detached +(`detachJourneyMainIpcCapture`), so its listener does not run for every +bridge call of the measured click. The settle is a snapshot, and Playwright's actionability checks run between it and the click. The probe and the IPC capture keep counting pre-click activity until the click event itself, and the ledger splits at the click stamp. So the record rejects an iteration