diff --git a/src/gateway/desktop/session-registry.ts b/src/gateway/desktop/session-registry.ts index 7a7c0b21bcb1..ff8154a366aa 100644 --- a/src/gateway/desktop/session-registry.ts +++ b/src/gateway/desktop/session-registry.ts @@ -224,6 +224,17 @@ export function createDesktopSessionRegistry( } } + /** + * Retires only owners strictly older than the claimant. An equal epoch shares the + * session, so fencing must not tear down a peer that claimed the same generation. + */ + async function stopSuperseded(sourceKey: string, ownerEpoch: number): Promise { + const entry = entries.get(sourceKey); + if (entry && entry.ownerEpoch < ownerEpoch) { + await stopEntry(entry); + } + } + async function stopAll(): Promise { await Promise.all([...entries.values()].map(stopEntry)); } @@ -235,6 +246,7 @@ export function createDesktopSessionRegistry( isOwnerEpochCurrent: (sourceKey: string, ownerEpoch: number) => claimedOwnerEpochs.get(sourceKey) === ownerEpoch, stop, + stopSuperseded, stopAll, }; } diff --git a/src/gateway/worker-environments/desktop-tunnel.test.ts b/src/gateway/worker-environments/desktop-tunnel.test.ts index 069f9469c490..d39d5e597cf8 100644 --- a/src/gateway/worker-environments/desktop-tunnel.test.ts +++ b/src/gateway/worker-environments/desktop-tunnel.test.ts @@ -396,4 +396,26 @@ describe("worker desktop tunnels", () => { await expect(launchApp(failed)).rejects.toThrow("launcher failed"); await failed.stopAll(); }); + + it("keeps a same-epoch desktop session alive when an app launch fences replaced owners", async () => { + const fake = fakeRunner(); + const manager = createWorkerDesktopTunnels({ runner: fake.runner }); + // The launcher claims the epoch first, so its fencing pass runs after the + // observer session for that same epoch already exists. Fencing must only + // retire strictly older owners; equal epochs share the session. + const launching = launchApp(manager, "browser", 1); + const starting = acquire(manager, 1); + await waitForStarts(fake.starts, 1); + fake.starts[0]?.process.becomeReady(); + await starting; + await launching; + + const observer = manager.attachObserver("worker:one", { + control: false, + ownerEpoch: 1, + close: vi.fn(), + }); + expect(observer).toBeDefined(); + observer?.release(); + }); }); diff --git a/src/gateway/worker-environments/desktop-tunnel.ts b/src/gateway/worker-environments/desktop-tunnel.ts index 632196f9bd84..60940d4b9990 100644 --- a/src/gateway/worker-environments/desktop-tunnel.ts +++ b/src/gateway/worker-environments/desktop-tunnel.ts @@ -106,7 +106,7 @@ export function createWorkerDesktopTunnels(deps: { }; const fenceReplacedOwners = async (environmentId: string, ownerEpoch: number): Promise => { - await sessions.stop(environmentId); + await sessions.stopSuperseded(environmentId, ownerEpoch); const staleLaunches = [...appLaunches.values()].filter( (entry) => entry.environmentId === environmentId && entry.ownerEpoch < ownerEpoch, );