fix(client): drop generation-scoped interaction state at generation death

Clearing waitingApprovals in handleConnected raced the reconnect replay:
mux frames flow from stream open while onConnected waits for the
readiness handshake, so a replayed approval/requested could land first
and be wiped — amber dot and answerable card lost until the next
generation. The sweep moves to generation death (onStateChange
'reconnecting'), before any next-generation frame can exist, and now
also drops buffered answerable frames (approval/question pairs) whose
dead-generation rpcIds could never be answered — a session instantiated
later no longer replays zombie takeover cards. session/queued buffering
already re-baselines per generation; this closes the same window for
the interaction frames.
This commit is contained in:
imccyu
2026-07-29 13:03:26 +08:00
parent 99b1a7e895
commit 9e10ba0f15
4 changed files with 55 additions and 6 deletions
@@ -154,6 +154,12 @@ export function apply(ctx: Context): void {
workspaces.handleConnected()
ctx.emit('connection/reset')
},
onStateChange: (state) => {
// Generation death fires before any next-generation frame can arrive
// (reconnect replays flow from stream open, ahead of onConnected):
// the only safe moment to drop generation-scoped interaction state.
if (state === 'reconnecting') sessions.handleDisconnected()
},
})
ctx.effect(() => () => { loop.stop() }, 'runtime: connection stream loop')
}
@@ -443,14 +443,32 @@ export class SessionManager {
}
}
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
// Approvals resolved while disconnected send no frame: drop the bits and
// let the mux-open replay re-add every still-pending question.
/**
* The moment a connection generation dies (before any next-generation frame
* can arrive — onConnected waits for the readiness handshake while replayed
* frames flow from stream open, so clearing there would race the replay):
* drop generation-scoped live state. Approvals resolved while disconnected
* send no frame, so the stale bits and the buffered answerable frames must
* not survive into the next generation — the mux-open replay re-adds every
* still-pending question with its live rpcId.
*/
handleDisconnected(): void {
if (this.waitingApprovals.size > 0) {
this.waitingApprovals.clear()
this.notifier.markDirty()
}
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
const kept = buffer.filter(item =>
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
if (kept.length === buffer.length) continue
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
else this.pendingBuffers.set(sessionId, kept)
}
}
/** After each connection generation: refresh the session baseline and rebuild opened windows. */
handleConnected(): void {
void this.refreshList()
for (const session of this.sessions.values()) void session.resync()
}
@@ -294,6 +294,11 @@ export class SessionsService implements ISessions {
this.manager.handleConnected()
}
/** Drop generation-scoped live interaction state the moment a connection generation dies. */
handleDisconnected(): void {
this.manager.handleDisconnected()
}
/**
* Create a session on the host. Resolution guarantee: by the time the
* promise resolves, the created session is in the list store and