fix: keep an empty proof delta distinguishable from a proof-less host

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.
This commit is contained in:
Neil
2026-09-07 19:07:15 -07:00
parent b141791f64
commit 2e248d9b60
4 changed files with 59 additions and 21 deletions
@@ -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)
})
})
@@ -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 }
}
}
@@ -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])
@@ -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)