fix(gateway): fence only superseded desktop sessions

The desktop core extraction replaced the owner fence's "stop strictly
older owners" check with an unconditional session stop. A launcher that
claims an owner epoch first, then reaches its async fencing pass after a
same-epoch observe has already created the session, tore that session
down and failed the observer with "stopped before connecting".

Restore the original invariant in the registry that owns it:
stopSuperseded() retires an entry only when its epoch is strictly lower
than the claimant's, so peers sharing a generation keep the session.

The regression test drives launch-then-observe at one epoch and fails on
the pre-fix code inside fenceReplacedOwners.
This commit is contained in:
Peter Steinberger
2026-08-11 23:11:58 -07:00
parent 4326be2c55
commit d49ae3c9d5
3 changed files with 35 additions and 1 deletions
+12
View File
@@ -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<void> {
const entry = entries.get(sourceKey);
if (entry && entry.ownerEpoch < ownerEpoch) {
await stopEntry(entry);
}
}
async function stopAll(): Promise<void> {
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,
};
}
@@ -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();
});
});
@@ -106,7 +106,7 @@ export function createWorkerDesktopTunnels(deps: {
};
const fenceReplacedOwners = async (environmentId: string, ownerEpoch: number): Promise<void> => {
await sessions.stop(environmentId);
await sessions.stopSuperseded(environmentId, ownerEpoch);
const staleLaunches = [...appLaunches.values()].filter(
(entry) => entry.environmentId === environmentId && entry.ownerEpoch < ownerEpoch,
);