From 2e248d9b6023525c077315fa6714cf5c506efd71 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:07:15 -0700 Subject: [PATCH] fix: keep an empty proof delta distinguishable from a proof-less host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A negotiated stream now sends retiredTerminalSurfaces: [] when nothing is new instead of omitting the field. Absence is the host's "I hold no proofs" signal — which is also what a recreated worktree's fresh host entry publishes — so the client ledger forgets on absence and a successor occupant never inherits its predecessor's proofs, even when the removed frame was missed. --- ...ession-tabs-retirement-proof-delta.test.ts | 15 ++++--- .../session-tabs-retirement-proof-delta.ts | 10 ++--- ...n-terminal-retirement-proof-ledger.test.ts | 43 ++++++++++++++++--- ...ession-terminal-retirement-proof-ledger.ts | 12 +++++- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts index 5400afdc518..021b6f20622 100644 --- a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.test.ts @@ -45,14 +45,16 @@ describe('session tabs retirement proof delta', () => { expect(project(frame(2, [proof(1), proof(2)]))).toEqual(frame(2, [proof(1), proof(2)])) }) - it('sends each proof once and omits the field when nothing is new', () => { + // Why `[]` rather than omitting the field: absence is the host's "I hold no proofs" signal and + // tells the client to forget, so a delta with nothing new must stay distinguishable from it. + it('sends each proof once and an empty list when nothing is new', () => { const project = createSessionTabsRetirementProofDelta([ SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY ]) expect(project(frame(1, [proof(1)]))).toEqual(frame(1, [proof(1)])) - expect(project(frame(2, [proof(1)]))).toEqual(frame(2)) + expect(project(frame(2, [proof(1)]))).toEqual(frame(2, [])) expect(project(frame(3, [proof(1), proof(2)]))).toEqual(frame(3, [proof(2)])) - expect(project(frame(4, [proof(1), proof(2)]))).toEqual(frame(4)) + expect(project(frame(4, [proof(1), proof(2)]))).toEqual(frame(4, [])) }) it('resends a proof that left the host list and came back', () => { @@ -143,15 +145,14 @@ describe('session.tabs.subscribe retirement proof payload', () => { const legacyTick = JSON.parse(legacy.tick).result const deltaTick = JSON.parse(delta.tick).result expect(legacyTick.retiredTerminalSurfaces).toHaveLength(64) - expect(deltaTick.retiredTerminalSurfaces).toBeUndefined() + expect(deltaTick.retiredTerminalSurfaces).toEqual([]) // Both clients still receive the full list on the initial snapshot. expect(JSON.parse(legacy.initial).result.retiredTerminalSurfaces).toHaveLength(64) expect(JSON.parse(delta.initial).result.retiredTerminalSurfaces).toHaveLength(64) + // The delta tick keeps a two-byte `[]` so the client can tell "nothing new" from "no proofs". const proofBytes = Buffer.byteLength(JSON.stringify(proofs)) expect(proofBytes).toBeGreaterThan(8_000) - expect(Buffer.byteLength(legacy.tick) - Buffer.byteLength(delta.tick)).toBeGreaterThanOrEqual( - proofBytes - ) + expect(Buffer.byteLength(legacy.tick) - Buffer.byteLength(delta.tick)).toBe(proofBytes - 2) }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts index b61be62cda5..059af124a78 100644 --- a/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts +++ b/src/main/runtime/rpc/methods/session-tabs-retirement-proof-delta.ts @@ -36,10 +36,10 @@ export function createSessionTabsRetirementProofDelta( : frame.retiredTerminalSurfaces // Why: track exactly the current list, so a proof that leaves and returns is sent again. sentByWorktree.set(frame.worktree, new Set(frame.retiredTerminalSurfaces.map(proofKey))) - if (fresh.length === frame.retiredTerminalSurfaces.length) { - return frame - } - const { retiredTerminalSurfaces: _sentProofs, ...rest } = frame - return (fresh.length > 0 ? { ...rest, retiredTerminalSurfaces: fresh } : rest) as TFrame + // Why: an empty list is a real signal ("nothing new, keep yours"). Omitting the field would be + // indistinguishable from a host that holds no proofs, which is what tells the client to forget. + return fresh.length === frame.retiredTerminalSurfaces.length + ? frame + : { ...frame, retiredTerminalSurfaces: fresh } } } diff --git a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts index 1c0225394ee..558c8ede234 100644 --- a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts +++ b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.test.ts @@ -38,6 +38,31 @@ function frame( describe('web session terminal retirement proof ledger', () => { beforeEach(() => clearRetainedTerminalRetirementProofsForTests()) + // Why: a recreated worktree keeps the same environment and worktree id but its fresh host entry + // holds no proofs and omits the field. Absence must forget, so the successor occupant never + // inherits its predecessor's proofs even when the removed frame was missed. A delta host with + // nothing new sends `[]`, which keeps what was retained. + it('forgets on an absent field but keeps proofs on an empty delta', () => { + mergeRetainedTerminalRetirementProofs( + ENVIRONMENT_ID, + frame(1, { retiredTerminalSurfaces: [retired] }) + ) + expect( + mergeRetainedTerminalRetirementProofs( + ENVIRONMENT_ID, + frame(2, { retiredTerminalSurfaces: [] }) + ).retiredTerminalSurfaces + ).toEqual([retired]) + const successor = frame(3) + expect(mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, successor)).toBe(successor) + expect( + mergeRetainedTerminalRetirementProofs( + ENVIRONMENT_ID, + frame(4, { retiredTerminalSurfaces: [] }) + ).retiredTerminalSurfaces + ).toEqual([]) + }) + it('carries a proof sent once into later delta frames for the same worktree', () => { expect( mergeRetainedTerminalRetirementProofs( @@ -46,11 +71,17 @@ describe('web session terminal retirement proof ledger', () => { ).retiredTerminalSurfaces ).toEqual([retired]) expect( - mergeRetainedTerminalRetirementProofs(ENVIRONMENT_ID, frame(2)).retiredTerminalSurfaces + mergeRetainedTerminalRetirementProofs( + ENVIRONMENT_ID, + frame(2, { retiredTerminalSurfaces: [] }) + ).retiredTerminalSurfaces ).toEqual([retired]) expect( - mergeRetainedTerminalRetirementProofs('other-environment', frame(3)).retiredTerminalSurfaces - ).toBeUndefined() + mergeRetainedTerminalRetirementProofs( + 'other-environment', + frame(3, { retiredTerminalSurfaces: [] }) + ).retiredTerminalSurfaces + ).toEqual([]) }) it('forgets a proof once the host publishes its surface live again', () => { @@ -146,11 +177,9 @@ describe('orphan recovery over delta frames', () => { const second = await recoverWebSessionTerminalOrphansBeforeApply( state, - frame(2), + frame(2, { retiredTerminalSurfaces: [] }), ENVIRONMENT_ID, - { - call: call as never - } + { call: call as never } ) expect(second?.tabs).toEqual([]) expect(second?.retiredTerminalSurfaces).toEqual([retired]) diff --git a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts index 4790c231a93..f33b12769c9 100644 --- a/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts +++ b/src/renderer/src/runtime/web-session-terminal-retirement-proof-ledger.ts @@ -36,15 +36,23 @@ export function mergeRetainedTerminalRetirementProofs( retainedByKey.delete(key) return snapshot } + // Why: a host that holds no proofs omits the field; a delta host with nothing new sends `[]`. + // Absence therefore means "forget" — which is also what a recreated worktree's fresh host entry + // publishes, so a new occupant never inherits its predecessor's proofs even if the removed + // frame was missed. + if (snapshot.retiredTerminalSurfaces === undefined) { + retainedByKey.delete(key) + return snapshot + } const connectionGeneration = getRuntimeEnvironmentConnectionGeneration(environmentId) const cached = retainedByKey.get(key) const retained = cached?.connectionGeneration === connectionGeneration ? cached.proofs : undefined - if (!retained && !snapshot.retiredTerminalSurfaces?.length) { + if (!retained && snapshot.retiredTerminalSurfaces.length === 0) { retainedByKey.delete(key) return snapshot } const merged = dropRetirementProofsForLiveSurfaces( - appendRetiredTerminalSurfaceProofs(retained, snapshot.retiredTerminalSurfaces ?? []), + appendRetiredTerminalSurfaceProofs(retained, snapshot.retiredTerminalSurfaces), snapshot.tabs ) retainedByKey.delete(key)