fix: make presentation sync disconnect lifecycle-safe

This commit is contained in:
lda
2026-07-14 12:08:56 +07:00 Verified
parent c8e519a563
commit c428d180d1
2 changed files with 52 additions and 15 deletions
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { ServerSyncMessage } from "@lda/presentation-sync"; import type { ServerSyncMessage } from "@lda/presentation-sync";
import type { PresentationPeer } from "./rooms.js"; import type { PresentationPeer, PresentationRoomService } from "./rooms.js";
import { import {
createPresentationRoomService, createPresentationRoomService,
EMPTY_ROOM_GRACE_MS, EMPTY_ROOM_GRACE_MS,
@@ -35,8 +35,7 @@ const makeService = () => {
}; };
}; };
const connectedRoom = () => { const connectRoom = (service: PresentationRoomService) => {
const { service, advance } = makeService();
const created = service.create({ const created = service.create({
role: "presenter", role: "presenter",
initialHash: "#scene/thesis/title", initialHash: "#scene/thesis/title",
@@ -52,7 +51,6 @@ const connectedRoom = () => {
audience.close.mockClear(); audience.close.mockClear();
return { return {
service, service,
advance,
presenter, presenter,
audience, audience,
presenterToken: created.connectionToken, presenterToken: created.connectionToken,
@@ -60,6 +58,11 @@ const connectedRoom = () => {
}; };
}; };
const connectedRoom = () => {
const { service, advance } = makeService();
return { ...connectRoom(service), advance };
};
describe("createPresentationRoomService", () => { describe("createPresentationRoomService", () => {
it("creates a room at revision zero and joins the opposite role", () => { it("creates a room at revision zero and joins the opposite role", () => {
const { service } = makeService(); const { service } = makeService();
@@ -131,6 +134,30 @@ describe("createPresentationRoomService", () => {
}); });
}); });
it("does not disconnect a replacement when the old peer closes", () => {
const { service } = makeService();
const created = service.create({ role: "presenter", initialHash: "#scene/one" });
const oldPeer = peer();
const newPeer = peer();
service.connect(created.connectionToken, oldPeer);
service.connect(created.connectionToken, newPeer);
newPeer.send.mockClear();
service.disconnect(created.connectionToken, oldPeer);
expect(service.publish(created.connectionToken, {
type: "location.publish",
hash: "#scene/replacement-survives",
baseRevision: 0,
messageId: "replacement-1",
}).kind).toBe("accepted");
expect(newPeer.send).toHaveBeenCalledWith({
type: "location.snapshot",
snapshot: { hash: "#scene/replacement-survives", revision: 1 },
originatingMessageId: "replacement-1",
});
});
it("accepts one publish and rejects a stale competing publish", () => { it("accepts one publish and rejects a stale competing publish", () => {
const { service, presenter, audience, presenterToken, audienceToken } = const { service, presenter, audience, presenterToken, audienceToken } =
connectedRoom(); connectedRoom();
@@ -171,7 +198,7 @@ describe("createPresentationRoomService", () => {
const { service, presenter, audience, presenterToken, audienceToken } = const { service, presenter, audience, presenterToken, audienceToken } =
connectedRoom(); connectedRoom();
service.disconnect(audienceToken); service.disconnect(audienceToken, audience);
expect(presenter.send).toHaveBeenCalledWith({ expect(presenter.send).toHaveBeenCalledWith({
type: "presence.snapshot", type: "presence.snapshot",
@@ -193,10 +220,11 @@ describe("createPresentationRoomService", () => {
presenter, presenter,
presenterToken, presenterToken,
audienceToken, audienceToken,
audience,
} = connectedRoom(); } = connectedRoom();
service.disconnect(presenterToken); service.disconnect(presenterToken, presenter);
service.disconnect(audienceToken); service.disconnect(audienceToken, audience);
advance(EMPTY_ROOM_GRACE_MS - 1); advance(EMPTY_ROOM_GRACE_MS - 1);
expect(service.sweepExpired()).toBe(0); expect(service.sweepExpired()).toBe(0);
@@ -206,10 +234,17 @@ describe("createPresentationRoomService", () => {
}); });
it("expires an empty room after the ten-minute grace", () => { it("expires an empty room after the ten-minute grace", () => {
const { service, advance, presenterToken, audienceToken } = connectedRoom(); const {
service,
advance,
presenterToken,
presenter,
audienceToken,
audience,
} = connectedRoom();
service.disconnect(presenterToken); service.disconnect(presenterToken, presenter);
service.disconnect(audienceToken); service.disconnect(audienceToken, audience);
advance(EMPTY_ROOM_GRACE_MS); advance(EMPTY_ROOM_GRACE_MS);
expect(service.sweepExpired()).toBe(1); expect(service.sweepExpired()).toBe(1);
@@ -281,10 +316,11 @@ describe("createPresentationRoomService", () => {
}); });
it("does not broadcast location changes between rooms", () => { it("does not broadcast location changes between rooms", () => {
const first = connectedRoom(); const { service } = makeService();
const second = connectedRoom(); const first = connectRoom(service);
const second = connectRoom(service);
expect(first.service.publish(first.presenterToken, { expect(service.publish(first.presenterToken, {
type: "location.publish", type: "location.publish",
hash: "#scene/first-room", hash: "#scene/first-room",
baseRevision: 0, baseRevision: 0,
@@ -245,14 +245,15 @@ export const createPresentationRoomService = (options: {
return { kind: "connected", snapshot: room.snapshot, presence }; return { kind: "connected", snapshot: room.snapshot, presence };
}, },
disconnect(token: string): void { disconnect(token: string, peer: PresentationPeer): void {
const membership = membershipByToken.get(token); const membership = membershipByToken.get(token);
const room = membership?.room; const room = membership?.room;
if ( if (
membership === undefined || membership === undefined ||
room === undefined || room === undefined ||
!roomsById.has(room.id) || !roomsById.has(room.id) ||
membership.peer === null membership.peer === null ||
membership.peer !== peer
) { ) {
return; return;
} }