From 6e7ceafd07bca3ca9e92e43b0548440dc8aac76a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:12:13 -0700 Subject: [PATCH] perf(mobile): avoid unchanged worktree catalog payloads (#11735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(mobile): avoid unchanged worktree catalog payloads * fix(mobile): isolate catalog snapshots by limit * review: reassert host truth on unchanged polls; content-address snapshots Client — the `changed` gate meant an unchanged poll skipped setWorktrees / setLastKnownWorktrees / setCachedWorktrees, so optimistic local edits (togglePin, handleDeleteWorktree's failure re-add) and the #8498 cache guard were no longer repaired while the host catalog was stable. The gate bought nothing: setCachedWorktrees is an in-memory Map write and areWorktreeListsEqual already ran every poll, so the steady state still short-circuits on array identity. All wire savings are unaffected. admit() now just returns the confirmed rows and HostScreen applies them exactly as it did pre-PR. Also on the client: - a stale response from a superseded client/host no longer clears the token the current client/host just established - discriminate on `worktrees` rather than on `'unchanged' in response`, so a future catalog field named `unchanged` can't reclassify a full response - useRef over useMemo for the snapshot client; React may discard memoized values - hoist WORKTREE_PS_FULL_LIMIT so the truncates-at-200 rationale travels with it Host — replace the per-limit snapshot cache with a content-addressed id (ETag semantics). Ownership lives in the id, so concurrent clients, differing limits, and runtime restarts are correct by construction; this drops the LRU, the eviction policy, the per-runtime WeakMap, and the retention of up to 8 full catalogs. The remaining cache is a pure memo: because ids derive from content, dropping or thrashing it costs CPU and nothing else. Keeping the memo also keeps the measured steady-state cost — hashing every poll instead measured 2.24ms vs 0.75ms for the compare on a 310KB catalog. Verified: mobile 2784 passed / 3 skipped, src/main/runtime/rpc 1064 passed, node + mobile typechecks, oxlint, oxfmt, max-lines ratchet. * fix(runtime): isolate catalog snapshot memo --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- mobile/app/h/[hostId]/index.tsx | 27 +-- mobile/app/index.tsx | 4 +- .../worktree-catalog-snapshot-client.test.ts | 182 ++++++++++++++++++ .../worktree-catalog-snapshot-client.ts | 107 ++++++++++ .../worktree-catalog-snapshot-method.test.ts | 60 ++++++ .../runtime/rpc/methods/worktree-schemas.ts | 3 +- src/main/runtime/rpc/methods/worktree.ts | 10 +- .../rpc/worktree-catalog-snapshot.test.ts | 83 ++++++++ .../runtime/rpc/worktree-catalog-snapshot.ts | 50 +++++ src/shared/runtime-types.ts | 13 ++ 10 files changed, 523 insertions(+), 16 deletions(-) create mode 100644 mobile/src/worktree/worktree-catalog-snapshot-client.test.ts create mode 100644 mobile/src/worktree/worktree-catalog-snapshot-client.ts create mode 100644 src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts create mode 100644 src/main/runtime/rpc/worktree-catalog-snapshot.test.ts create mode 100644 src/main/runtime/rpc/worktree-catalog-snapshot.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d88fb6699aa..c7052a39777 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -94,6 +94,7 @@ import { import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections' import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage' import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot' +import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client' import { repoColor } from '../../../src/worktree/repo-color' import { WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, @@ -141,6 +142,9 @@ export function HostScreen({ const lastConnectedAt = useLastConnectedAt(hostId) const clientRef = useRef(null) const fetchWorktreesInFlightRef = useRef(false) + // Why: useRef, not useMemo — React may discard memoized values, which would silently + // reset the snapshot token this object exists to own. + const worktreeCatalogRef = useRef(new WorktreeCatalogSnapshotClient()) const fetchRepoMetadataInFlightRef = useRef(new WeakSet()) const fetchRepoMetadataPendingRef = useRef(new WeakSet()) const repoMetadataFetchedAtRef = useRef(0) @@ -423,31 +427,32 @@ export function HostScreen({ const requestHostId = hostId try { - // Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces. - const response = await requestClient.sendRequest('worktree.ps', { limit: 10000 }) + const pendingCatalog = await worktreeCatalogRef.current.fetch(requestClient, requestHostId) if (clientRef.current !== requestClient || hostId !== requestHostId) { return } if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) { return } - if (response.ok) { - const result = (response as RpcSuccess).result as { worktrees: Worktree[] } + // Why: unchanged responses still yield the confirmed rows, so every poll reasserts + // host truth over optimistic local edits regardless of payload size. + const confirmed = worktreeCatalogRef.current.admit(pendingCatalog) + if (confirmed) { // Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path. setWorktrees((current) => - areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees + areWorktreeListsEqual(current, confirmed) ? current : confirmed ) setLastKnownWorktrees((current) => - areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees + areWorktreeListsEqual(current, confirmed) ? current : confirmed ) setWorktreesLoaded(true) // Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list. if (hostId) { - setCachedWorktrees(hostId, result.worktrees) + setCachedWorktrees(hostId, confirmed) } // Drop the optimistic active override once the host reports it active, so later desktop changes win. setOptimisticActiveWorktreeId((pending) => - pending && result.worktrees.some((w) => w.worktreeId === pending && w.isActive) + pending && confirmed.some((w) => w.worktreeId === pending && w.isActive) ? null : pending ) @@ -459,7 +464,7 @@ export function HostScreen({ } const still = new Set() for (const id of prev) { - const wt = result.worktrees.find((w) => w.worktreeId === id) + const wt = confirmed.find((w) => w.worktreeId === id) if (wt && wt.liveTerminalCount > 0) { still.add(id) } @@ -468,9 +473,7 @@ export function HostScreen({ }) // Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage. - const serverPinned = new Set( - result.worktrees.filter((w) => w.isPinned).map((w) => w.worktreeId) - ) + const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId)) setPinnedIds((prev) => { if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) { return prev diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index e04f730c65e..0962550f58b 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -19,6 +19,7 @@ import { loadHosts } from '../src/transport/host-store' import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' +import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client' import type { RpcClient } from '../src/transport/rpc-client' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { @@ -178,8 +179,7 @@ function fetchWorktreeInfo( }) } - // Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate. - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 }) + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) .then((response) => { if (disposed()) { return diff --git a/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts b/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts new file mode 100644 index 00000000000..35eb414dbd2 --- /dev/null +++ b/mobile/src/worktree/worktree-catalog-snapshot-client.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + admitWorktreeCatalogResponse, + WORKTREE_PS_FULL_LIMIT, + WorktreeCatalogSnapshotClient +} from './worktree-catalog-snapshot-client' + +describe('admitWorktreeCatalogResponse', () => { + it('accepts new-host full and unchanged responses', () => { + const full = admitWorktreeCatalogResponse( + { worktrees: [{ id: 'worktree-1' }], snapshotId: 'snapshot-1' }, + null + ) + const unchanged = admitWorktreeCatalogResponse( + { unchanged: true, snapshotId: 'snapshot-1' }, + 'snapshot-1' + ) + + expect(full).toEqual({ + kind: 'full', + snapshotId: 'snapshot-1', + worktrees: [{ id: 'worktree-1' }] + }) + expect(unchanged).toEqual({ kind: 'unchanged', snapshotId: 'snapshot-1' }) + }) + + it('treats an old-host full response as authoritative and clears the token', () => { + expect( + admitWorktreeCatalogResponse({ worktrees: [{ id: 'worktree-1' }] }, 'stale-token') + ).toEqual({ + kind: 'full', + snapshotId: null, + worktrees: [{ id: 'worktree-1' }] + }) + }) + + it('classifies by rows, so a future `unchanged` catalog field cannot hide a full response', () => { + expect( + admitWorktreeCatalogResponse( + { worktrees: [{ id: 'worktree-1' }], unchanged: false, snapshotId: 'snapshot-1' }, + 'snapshot-1' + ) + ).toEqual({ + kind: 'full', + snapshotId: 'snapshot-1', + worktrees: [{ id: 'worktree-1' }] + }) + }) + + it('rejects unchanged responses for a snapshot the client does not own', () => { + expect( + admitWorktreeCatalogResponse({ unchanged: true, snapshotId: 'snapshot-2' }, 'snapshot-1') + ).toEqual({ kind: 'invalid' }) + expect(admitWorktreeCatalogResponse({ unchanged: true }, 'snapshot-1')).toEqual({ + kind: 'invalid' + }) + }) + + it('rejects malformed success payloads', () => { + expect(admitWorktreeCatalogResponse(null, 'snapshot-1')).toEqual({ kind: 'invalid' }) + expect(admitWorktreeCatalogResponse({ unchanged: false }, 'snapshot-1')).toEqual({ + kind: 'invalid' + }) + }) + + it('accepts a full response but ignores an out-of-bounds snapshot id', () => { + expect( + admitWorktreeCatalogResponse({ worktrees: [], snapshotId: 'x'.repeat(129) }, null) + ).toEqual({ kind: 'full', snapshotId: null, worktrees: [] }) + }) +}) + +function clientWithResults(...results: unknown[]): RpcClient { + return { + sendRequest: vi.fn( + async () => + ({ + id: 'request', + ok: true, + result: results.shift(), + _meta: { runtimeId: 'runtime' } + }) as const + ) + } as unknown as RpcClient +} + +describe('WorktreeCatalogSnapshotClient', () => { + it('returns the confirmed rows on unchanged responses so callers can reassert them', async () => { + const rows = [{ worktreeId: 'worktree-1' }] + const client = clientWithResults( + { worktrees: rows, snapshotId: 'snapshot-1' }, + { unchanged: true, snapshotId: 'snapshot-1' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + const first = snapshots.admit(await snapshots.fetch(client, 'host-1')) + const second = snapshots.admit(await snapshots.fetch(client, 'host-1')) + + expect(first).toEqual(rows) + expect(second).toEqual(rows) + }) + + it('does not advance the token until the caller admits a response', async () => { + const client = clientWithResults( + { worktrees: [], snapshotId: 'snapshot-1' }, + { worktrees: [], snapshotId: 'snapshot-1' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + await snapshots.fetch(client, 'host-1') + snapshots.admit(await snapshots.fetch(client, 'host-1')) + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: null + }) + }) + + it('preserves the last admitted token across transport failures', async () => { + const client = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' }) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(client, 'host-1')) + snapshots.admit(null) + await snapshots.fetch(client, 'host-1') + + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: 'snapshot-1' + }) + }) + + it('clears snapshot ownership after a mismatched unchanged response', async () => { + const client = clientWithResults( + { worktrees: [], snapshotId: 'snapshot-1' }, + { unchanged: true, snapshotId: 'snapshot-2' }, + { worktrees: [], snapshotId: 'snapshot-3' } + ) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(client, 'host-1')) + snapshots.admit(await snapshots.fetch(client, 'host-1')) + await snapshots.fetch(client, 'host-1') + + expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: null + }) + }) + + it('resets snapshot ownership when the client or host changes', async () => { + const firstClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' }) + const secondClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-2' }) + const snapshots = new WorktreeCatalogSnapshotClient() + + snapshots.admit(await snapshots.fetch(firstClient, 'host-1')) + await snapshots.fetch(secondClient, 'host-2') + + expect(secondClient.sendRequest).toHaveBeenCalledWith('worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: null + }) + }) + + it('drops a superseded host response without invalidating the current token', async () => { + const firstClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-1' }) + const secondClient = clientWithResults({ worktrees: [], snapshotId: 'snapshot-2' }) + const snapshots = new WorktreeCatalogSnapshotClient() + + // Host A's response is still in flight when the screen switches to host B. + const stale = await snapshots.fetch(firstClient, 'host-1') + snapshots.admit(await snapshots.fetch(secondClient, 'host-2')) + expect(snapshots.admit(stale)).toBeNull() + + await snapshots.fetch(secondClient, 'host-2') + expect(secondClient.sendRequest).toHaveBeenNthCalledWith(2, 'worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: 'snapshot-2' + }) + }) +}) diff --git a/mobile/src/worktree/worktree-catalog-snapshot-client.ts b/mobile/src/worktree/worktree-catalog-snapshot-client.ts new file mode 100644 index 00000000000..8b66ceddddd --- /dev/null +++ b/mobile/src/worktree/worktree-catalog-snapshot-client.ts @@ -0,0 +1,107 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { Worktree } from './workspace-list-sections' + +// Why: worktree.ps silently truncates at 200; use a high cap so large hosts don't drop workspaces. +export const WORKTREE_PS_FULL_LIMIT = 10_000 + +export type WorktreeCatalogAdmission = + | { kind: 'full'; snapshotId: string | null; worktrees: T[] } + | { kind: 'unchanged'; snapshotId: string } + | { kind: 'invalid' } + +type PendingWorktreeCatalog = { + admission: WorktreeCatalogAdmission + client: RpcClient + hostId: string +} + +function validSnapshotId(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 && value.length <= 128 ? value : null +} + +export function admitWorktreeCatalogResponse( + result: unknown, + requestedSnapshotId: string | null +): WorktreeCatalogAdmission { + if (!result || typeof result !== 'object') { + return { kind: 'invalid' } + } + const response = result as { + snapshotId?: unknown + unchanged?: unknown + worktrees?: unknown + } + // Why: a full response is defined by carrying rows, so discriminate on that rather + // than on the presence of `unchanged` — the latter could collide with a future + // catalog field and silently reclassify a full response. + if (Array.isArray(response.worktrees)) { + return { + kind: 'full', + snapshotId: validSnapshotId(response.snapshotId), + worktrees: response.worktrees as T[] + } + } + + const snapshotId = validSnapshotId(response.snapshotId) + if (response.unchanged === true && snapshotId && snapshotId === requestedSnapshotId) { + return { kind: 'unchanged', snapshotId } + } + return { kind: 'invalid' } +} + +export class WorktreeCatalogSnapshotClient { + private client: RpcClient | null = null + private hostId: string | null = null + private snapshotId: string | null = null + private confirmedWorktrees: Worktree[] | null = null + + async fetch(client: RpcClient, hostId: string): Promise { + if (this.client !== client || this.hostId !== hostId) { + this.client = client + this.hostId = hostId + this.snapshotId = null + this.confirmedWorktrees = null + } + const requestedSnapshotId = this.snapshotId + const response = await client.sendRequest('worktree.ps', { + limit: WORKTREE_PS_FULL_LIMIT, + afterSnapshotId: requestedSnapshotId + }) + if (!response.ok) { + return null + } + return { + admission: admitWorktreeCatalogResponse( + (response as RpcSuccess).result, + requestedSnapshotId + ), + client, + hostId + } + } + + /** The confirmed host catalog to apply, or null when there is nothing to apply. */ + admit(pending: PendingWorktreeCatalog | null): Worktree[] | null { + if (!pending) { + return null + } + // Why: a response from a superseded client/host is stale, not wrong — dropping it + // must not invalidate the token the current client/host just established. + if (pending.client !== this.client || pending.hostId !== this.hostId) { + return null + } + if (pending.admission.kind === 'invalid') { + this.snapshotId = null + return null + } + + this.snapshotId = pending.admission.snapshotId + if (pending.admission.kind === 'full') { + this.confirmedWorktrees = pending.admission.worktrees + } + // Why: unchanged responses still return the confirmed rows so every poll reasserts + // host truth over optimistic local edits, exactly as the full-payload path did. + return this.confirmedWorktrees + } +} diff --git a/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts new file mode 100644 index 00000000000..c7efa0bbe43 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-catalog-snapshot-method.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import { WORKTREE_METHODS } from './worktree' + +function makeRuntime() { + return { + getRuntimeId: () => 'test-runtime', + getWorktreePs: vi.fn().mockResolvedValue({ + worktrees: [], + totalCount: 0, + truncated: false + }) + } as unknown as OrcaRuntimeService +} + +describe('worktree.ps catalog snapshots', () => { + it('preserves the exact legacy response when no snapshot field is sent', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const response = await dispatcher.dispatch({ + id: 'legacy', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000 } + }) + + expect(response).toMatchObject({ + ok: true, + result: { worktrees: [], totalCount: 0, truncated: false } + }) + expect((response as { result: unknown }).result).not.toHaveProperty('snapshotId') + }) + + it('returns a full snapshot followed by a tiny unchanged response', async () => { + const runtime = makeRuntime() + const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS }) + const first = await dispatcher.dispatch({ + id: 'first', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000, afterSnapshotId: null } + }) + const snapshotId = (first as { result: { snapshotId: string } }).result.snapshotId + + const second = await dispatcher.dispatch({ + id: 'second', + authToken: 'token', + method: 'worktree.ps', + params: { limit: 10_000, afterSnapshotId: snapshotId } + }) + + expect(snapshotId).toEqual(expect.any(String)) + expect(second).toMatchObject({ + ok: true, + result: { unchanged: true, snapshotId } + }) + expect(runtime.getWorktreePs).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index caf8dd025ff..069a8499d18 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -57,7 +57,8 @@ export const WorktreeTeardownMissingTerminalsParams = WorktreeDetectedListParams }) export const WorktreePsParams = z.object({ - limit: OptionalFiniteNumber + limit: OptionalFiniteNumber, + afterSnapshotId: z.string().min(1).max(128).nullable().optional() }) export const WorktreeSortOrder = z.object({ diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 9c6205ed86d..dfa81dde1f5 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -5,6 +5,7 @@ import { } from '../../../automations/workspace-provenance' import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' +import { resolveWorktreeCatalogSnapshot } from '../worktree-catalog-snapshot' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { WorktreeCreate, @@ -27,7 +28,14 @@ export const WORKTREE_METHODS: RpcMethod[] = [ defineMethod({ name: 'worktree.ps', params: WorktreePsParams, - handler: async (params, { runtime }) => runtime.getWorktreePs(params.limit) + handler: async (params, { runtime }) => { + const result = await runtime.getWorktreePs(params.limit) + // Why: callers that never send the field get the byte-exact legacy response. + if (params.afterSnapshotId === undefined) { + return result + } + return resolveWorktreeCatalogSnapshot(result, params.afterSnapshotId) + } }), defineMethod({ name: 'worktree.list', diff --git a/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts b/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts new file mode 100644 index 00000000000..4f52319e523 --- /dev/null +++ b/src/main/runtime/rpc/worktree-catalog-snapshot.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeWorktreePsResult } from '../../../shared/runtime-types' +import { resolveWorktreeCatalogSnapshot } from './worktree-catalog-snapshot' + +function result(totalCount: number): RuntimeWorktreePsResult { + return { worktrees: [], totalCount, truncated: false } +} + +describe('resolveWorktreeCatalogSnapshot', () => { + it('returns unchanged only when the caller owns the exact current snapshot', () => { + const first = resolveWorktreeCatalogSnapshot(result(1), null) + expect(first).toMatchObject({ totalCount: 1 }) + + const snapshotId = first.snapshotId + expect(resolveWorktreeCatalogSnapshot(result(1), snapshotId)).toEqual({ + unchanged: true, + snapshotId + }) + expect(resolveWorktreeCatalogSnapshot(result(1), 'unknown')).toEqual({ + ...result(1), + snapshotId + }) + }) + + it('issues a new snapshot id for changed content', () => { + const first = resolveWorktreeCatalogSnapshot(result(1), null) + const changed = resolveWorktreeCatalogSnapshot(result(2), first.snapshotId) + + expect(changed.snapshotId).not.toBe(first.snapshotId) + expect(changed).not.toHaveProperty('unchanged') + }) + + it('keeps concurrent callers independent without server-held state', () => { + // Two clients on different catalogs interleave; neither can displace the other. + const wide = resolveWorktreeCatalogSnapshot(result(2), null) + const narrow = resolveWorktreeCatalogSnapshot(result(1), null) + + expect(resolveWorktreeCatalogSnapshot(result(2), wide.snapshotId)).toEqual({ + unchanged: true, + snapshotId: wide.snapshotId + }) + expect(resolveWorktreeCatalogSnapshot(result(1), narrow.snapshotId)).toEqual({ + unchanged: true, + snapshotId: narrow.snapshotId + }) + }) + + it('repairs a caller holding a superseded snapshot with a full response', () => { + const older = resolveWorktreeCatalogSnapshot(result(1), null) + + const repaired = resolveWorktreeCatalogSnapshot(result(2), older.snapshotId) + expect(repaired).toMatchObject({ totalCount: 2 }) + expect(repaired).not.toHaveProperty('unchanged') + expect(repaired.snapshotId).not.toBe(older.snapshotId) + }) + + it('derives ids from content alone, so memo state cannot change the answer', () => { + // Identical catalogs must yield identical ids whether the memo is warm or was just + // displaced — that is what makes the memo droppable and restarts safe. + const warm = resolveWorktreeCatalogSnapshot(result(1), null).snapshotId + resolveWorktreeCatalogSnapshot(result(99), null) + + expect(resolveWorktreeCatalogSnapshot(result(1), null).snapshotId).toBe(warm) + }) + + it('isolates the memo from mutation of a previously resolved catalog', () => { + const mutable = result(1) + const first = resolveWorktreeCatalogSnapshot(mutable, null) + + mutable.totalCount = 2 + const changed = resolveWorktreeCatalogSnapshot(mutable, first.snapshotId) + + expect(changed).toMatchObject({ totalCount: 2 }) + expect(changed).not.toHaveProperty('unchanged') + expect(changed.snapshotId).not.toBe(first.snapshotId) + }) + + it('produces ids within the request schema bound', () => { + const { snapshotId } = resolveWorktreeCatalogSnapshot(result(1), null) + expect(snapshotId.length).toBeGreaterThan(0) + expect(snapshotId.length).toBeLessThanOrEqual(128) + }) +}) diff --git a/src/main/runtime/rpc/worktree-catalog-snapshot.ts b/src/main/runtime/rpc/worktree-catalog-snapshot.ts new file mode 100644 index 00000000000..ca5fc944879 --- /dev/null +++ b/src/main/runtime/rpc/worktree-catalog-snapshot.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import type { + RuntimeWorktreePsConditionalResult, + RuntimeWorktreePsResult +} from '../../../shared/runtime-types' + +// Why: 192 bits of a sha256 over the serialized catalog. Accidental collision is +// impossible at any realistic poll rate, and this is not an auth boundary. +const SNAPSHOT_ID_LENGTH = 32 + +type MemoizedCatalogId = { + result: RuntimeWorktreePsResult + snapshotId: string +} + +// Why: a pure memo over the most recent catalog. Because ids are derived from content +// rather than from cache state, dropping or thrashing this slot costs CPU and nothing +// else — it can never produce a wrong answer, so it needs no scoping, eviction policy, +// or per-caller isolation. Serializing every poll instead would cost ~3x the compare. +let memoizedId: MemoizedCatalogId | null = null + +function worktreeCatalogSnapshotId(result: RuntimeWorktreePsResult): string { + if (memoizedId && isDeepStrictEqual(memoizedId.result, result)) { + return memoizedId.snapshotId + } + const serialized = JSON.stringify(result) + const snapshotId = createHash('sha256') + .update(serialized) + .digest('base64url') + .slice(0, SNAPSHOT_ID_LENGTH) + // Why: caller-owned mutation must not let an old id label new catalog content. + memoizedId = { result: JSON.parse(serialized) as RuntimeWorktreePsResult, snapshotId } + return snapshotId +} + +// Why: content-addressed like an HTTP ETag, so snapshot ownership lives entirely in the +// id. Any number of concurrent clients, any `limit`, and any runtime restart stay correct +// by construction: ids match only when the catalogs are byte-identical, which is exactly +// when `unchanged` is the right answer. +export function resolveWorktreeCatalogSnapshot( + result: RuntimeWorktreePsResult, + afterSnapshotId: string | null +): RuntimeWorktreePsConditionalResult { + const snapshotId = worktreeCatalogSnapshotId(result) + if (afterSnapshotId === snapshotId) { + return { unchanged: true, snapshotId } + } + return { ...result, snapshotId } +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 5eca1063ab8..d0f98e68360 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -843,6 +843,19 @@ export type RuntimeWorktreePsResult = { truncated: boolean } +export type RuntimeWorktreePsSnapshotResult = RuntimeWorktreePsResult & { + snapshotId: string +} + +export type RuntimeWorktreePsUnchangedResult = { + unchanged: true + snapshotId: string +} + +export type RuntimeWorktreePsConditionalResult = + | RuntimeWorktreePsSnapshotResult + | RuntimeWorktreePsUnchangedResult + export type RuntimeRepoList = { repos: Repo[] }