Skip to content

Commit bc18454

Browse files
authored
fix(desktop): forget a Host-removed Session registration on reconnect restore (#4764)
1 parent 41696e3 commit bc18454

3 files changed

Lines changed: 95 additions & 3 deletions

File tree

apps/desktop/src/main/__tests__/runtime-host-desktop-candidate.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type {
3030
ConnectOrSpawnRuntimeHostInput,
3131
RuntimeHostConnection,
3232
} from '@maka/runtime-host/client';
33+
import { RuntimeHostOperationError } from '@maka/runtime-host/client';
3334
import {
3435
SESSION_CONTINUITY_SCHEMA_VERSION,
3536
type ClientCapabilityCallFrame,
@@ -895,6 +896,69 @@ test('drops a stale shared Session observation when Guest access is gone', async
895896
await observations.close();
896897
});
897898

899+
test('forgets an observed Session the Host no longer serves instead of blocking every reconnect', async () => {
900+
const observations = new RuntimeHostSessionObservationRegistry();
901+
const firstIpc = ipcHarness();
902+
const firstHost = connectionHarness('missing-session-source', {
903+
sessionId: 'session-1',
904+
subscriptionSnapshot: continuitySnapshot(),
905+
});
906+
const firstCandidate = await createDesktopRuntimeHostCandidate(
907+
firstHost.connection,
908+
deps(firstIpc),
909+
observations,
910+
);
911+
await firstIpc.invoke('sessions:observe', 'session-1', 'observer-1');
912+
await firstCandidate.close();
913+
914+
// The replacement Host no longer serves session-1: subscription.open
915+
// deterministically answers not_found.
916+
const changes: Array<{ reason: string; sessionId?: string }> = [];
917+
const missingHost = connectionHarness('missing-session-host', {
918+
sessionId: 'session-1',
919+
subscriptionError: new RuntimeHostOperationError(
920+
'subscription.open',
921+
'not_found',
922+
'Runtime Host Session was not found',
923+
),
924+
});
925+
const secondCandidate = await createDesktopRuntimeHostCandidate(
926+
missingHost.connection,
927+
{
928+
...deps(ipcHarness()),
929+
emitSessionsChanged: (_scope, reason, sessionId) => {
930+
changes.push({ reason, ...(sessionId === undefined ? {} : { sessionId }) });
931+
},
932+
},
933+
observations,
934+
);
935+
936+
// The stale active registration is forgotten instead of failing the
937+
// candidate start, and the renderer is told to drop the Session view.
938+
assert.deepEqual(observations.observedSessionIds(), []);
939+
assert.ok(
940+
changes.some(
941+
({ reason, sessionId }) => reason === 'deleted' && sessionId === 'session-1',
942+
),
943+
);
944+
await secondCandidate.close();
945+
946+
// A later reconnect observes new Sessions on the same registry.
947+
const thirdIpc = ipcHarness();
948+
const thirdHost = connectionHarness('missing-session-recovered', {
949+
sessionId: 'session-2',
950+
});
951+
const thirdCandidate = await createDesktopRuntimeHostCandidate(
952+
thirdHost.connection,
953+
deps(thirdIpc),
954+
observations,
955+
);
956+
await thirdIpc.invoke('sessions:observe', 'session-2', 'observer-2');
957+
assert.deepEqual(observations.observedSessionIds(), ['session-2']);
958+
await thirdCandidate.close();
959+
await observations.close();
960+
});
961+
898962
type IpcHandler = Parameters<Pick<IpcMain, 'handle'>['handle']>[1];
899963

900964
function ipcHarness(onSend?: (channel: string, payload: unknown) => void) {

apps/desktop/src/main/runtime-host-desktop-candidate.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -703,11 +703,14 @@ export async function createDesktopRuntimeHostCandidate(
703703
once: target.once.bind(target),
704704
off: target.off.bind(target),
705705
}),
706+
(missingSessionId) => emitSessionsChanged("deleted", missingSessionId),
706707
);
707708
const restoredSessionIdSet = new Set(restoredSessionIds);
708-
const failedSessionIds = observedSessionIds.filter(
709-
(sessionId) => !restoredSessionIdSet.has(sessionId),
710-
);
709+
// Attach forgets Sessions the Host no longer serves, so only Sessions
710+
// that are still registered but failed to restore count as failures.
711+
const failedSessionIds = sessionObservations
712+
.observedSessionIds()
713+
.filter((sessionId) => !restoredSessionIdSet.has(sessionId));
711714
if (failedSessionIds.length > 0) {
712715
throw new Error(
713716
`Failed to restore Session observations: ${failedSessionIds.join(', ')}`,

apps/desktop/src/main/runtime-host-session-observation-registry.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
* under the License.
1818
*/
1919

20+
import { RuntimeHostOperationError } from "@maka/runtime-host/client";
2021
import type {
2122
RuntimeHostSessionObserver,
2223
RuntimeHostRendererTarget,
@@ -74,6 +75,20 @@ function requireTranscriptSource(
7475
return source as SessionObservationSource & TranscriptSource;
7576
}
7677

78+
/**
79+
* A `subscription.open`/`not_found` answer is deterministic: the Host no
80+
* longer serves this Session (Host restart with ephemeral state, Session GC,
81+
* or deletion by another client). Unlike `session.transcript.page`/`not_found`
82+
* (see `isRecoverableSubscriptionFailure` in the subscription owner), there is
83+
* nothing to retry — the registration must be forgotten instead of blocking
84+
* every reconnect.
85+
*/
86+
function isMissingRuntimeHostSessionError(error: unknown): boolean {
87+
if (!(error instanceof RuntimeHostOperationError)) return false;
88+
if (error.operation !== "subscription.open") return false;
89+
return error.code === "not_found";
90+
}
91+
7792
interface SessionObservationRegistration {
7893
readonly sessionId: string;
7994
readonly messageAdmissions: boolean;
@@ -139,6 +154,7 @@ export class RuntimeHostSessionObservationRegistry {
139154
async attach(
140155
source: SessionObservationSource,
141156
bindTarget: ObservationTargetBinding = (target) => target,
157+
onSessionMissing?: (sessionId: string) => void,
142158
): Promise<string[]> {
143159
this.#assertOpen();
144160
if (this.#source && this.#source !== source) {
@@ -176,6 +192,15 @@ export class RuntimeHostSessionObservationRegistry {
176192
this.#source === source &&
177193
this.#registrations.get(observerId) === registration
178194
) {
195+
if (isMissingRuntimeHostSessionError(error)) {
196+
// The Host no longer serves this Session. Forget the
197+
// registration regardless of lifecycle so the stale entry
198+
// cannot fail every future reconnect, and let the upper layer
199+
// drop the Session view.
200+
onSessionMissing?.(registration.sessionId);
201+
this.#deleteRegistration(observerId, registration);
202+
return undefined;
203+
}
179204
if (registration.lifecycle === "pending") {
180205
this.#deleteRegistration(observerId, registration);
181206
}

0 commit comments

Comments
 (0)