From f57799c060d45f2356eef26b3a221f72c071c426 Mon Sep 17 00:00:00 2001 From: Jason Hartman Date: Mon, 7 Oct 2024 12:40:38 -0700 Subject: [PATCH] feat(client-presence): System Workspace (#22670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add infrastructure for custom System Workspace to handle internal states including ClientConnectionId to ClientSessionId. 2. Move audience support to System Workspace 3. Add `attendeeJoined` implementation 4. Add test coverage for new attendee's joining and consistency of lookup. New test cases: ``` PresenceManager ✔ throws when unknown attendee is requested via `getAttendee` when connected attendee ✔ is announced via `attendeeJoined` when new already known ✔ is available from `getAttendee` by connection id ✔ is available from `getAttendee` by session id ✔ is available from `getAttendees` ✔ is NOT announced when rejoined with same connection (duplicate signal) ✔ is NOT announced when rejoined with different connection and current information is updated ``` 5. Update general update protocol to always include client connection id -> current session id entry to ensure all updates are always working with known (already registered) session id even if a prior join or broadcast were missed. --- .../framework/presence/src/internalTypes.ts | 12 +- .../presence/src/presenceDatastoreManager.ts | 95 ++++---- .../framework/presence/src/presenceManager.ts | 124 ++++++---- .../framework/presence/src/systemWorkspace.ts | 226 ++++++++++++++++++ .../presence/src/test/presenceManager.spec.ts | 179 +++++++++++++- .../framework/presence/src/test/testUtils.ts | 33 ++- 6 files changed, 546 insertions(+), 123 deletions(-) create mode 100644 packages/framework/presence/src/systemWorkspace.ts diff --git a/packages/framework/presence/src/internalTypes.ts b/packages/framework/presence/src/internalTypes.ts index 62c9c9619d7c..767366ac50e8 100644 --- a/packages/framework/presence/src/internalTypes.ts +++ b/packages/framework/presence/src/internalTypes.ts @@ -5,10 +5,9 @@ import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal"; import type { IFluidDataStoreRuntime } from "@fluidframework/datastore-definitions/internal"; -import type { MonitoringContext } from "@fluidframework/telemetry-utils/internal"; import type { InternalTypes } from "./exposedInternalTypes.js"; -import type { ClientSessionId, IPresence, ISessionClient } from "./presence.js"; +import type { ClientSessionId, ISessionClient } from "./presence.js"; import type { IRuntimeInternal } from "@fluid-experimental/presence/internal/container-definitions/internal"; @@ -48,15 +47,6 @@ export type IEphemeralRuntime = Pick< > & Partial>; -/** - * Collection of utilities provided by PresenceManager that are used by presence sub-components. - * - * @internal - */ -export type PresenceManagerInternal = Pick & { - readonly mc: MonitoringContext | undefined; -}; - /** * @internal */ diff --git a/packages/framework/presence/src/presenceDatastoreManager.ts b/packages/framework/presence/src/presenceDatastoreManager.ts index 4a4d5abfb680..ccb90ea6517f 100644 --- a/packages/framework/presence/src/presenceDatastoreManager.ts +++ b/packages/framework/presence/src/presenceDatastoreManager.ts @@ -5,18 +5,23 @@ import { assert } from "@fluidframework/core-utils/internal"; import type { IInboundSignalMessage } from "@fluidframework/runtime-definitions/internal"; +import type { ITelemetryLoggerExt } from "@fluidframework/telemetry-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; -import type { InternalTypes } from "./exposedInternalTypes.js"; -import type { IEphemeralRuntime, PresenceManagerInternal } from "./internalTypes.js"; -import type { ClientSessionId } from "./presence.js"; +import type { IEphemeralRuntime } from "./internalTypes.js"; +import type { ClientSessionId, ISessionClient } from "./presence.js"; import type { ClientUpdateEntry, PresenceStatesInternal, ValueElementMap, } from "./presenceStates.js"; import { createPresenceStates, mergeUntrackedDatastore } from "./presenceStates.js"; -import type { PresenceStates, PresenceStatesSchema } from "./types.js"; +import type { SystemWorkspaceDatastore } from "./systemWorkspace.js"; +import type { + PresenceStates, + PresenceStatesSchema, + PresenceWorkspaceAddress, +} from "./types.js"; import type { IExtensionMessage } from "@fluid-experimental/presence/internal/container-definitions/internal"; @@ -26,18 +31,14 @@ interface PresenceStatesEntry { } interface SystemDatastore { - "system:presence": { - clientToSessionId: { - [ - ClientConnectionId: ClientConnectionId - ]: InternalTypes.ValueRequiredState; - }; - }; + "system:presence": SystemWorkspaceDatastore; } -type PresenceDatastore = { +type InternalWorkspaceAddress = `${"s" | "n"}:${PresenceWorkspaceAddress}`; + +type PresenceDatastore = SystemDatastore & { [WorkspaceAddress: string]: ValueElementMap; -} & SystemDatastore; +}; interface GeneralDatastoreMessageContent { [WorkspaceAddress: string]: { @@ -47,7 +48,7 @@ interface GeneralDatastoreMessageContent { }; } -type DatastoreMessageContent = GeneralDatastoreMessageContent & SystemDatastore; +type DatastoreMessageContent = SystemDatastore & GeneralDatastoreMessageContent; const datastoreUpdateMessageType = "Pres:DatastoreUpdate"; interface DatastoreUpdateMessage extends IInboundSignalMessage { @@ -56,7 +57,7 @@ interface DatastoreUpdateMessage extends IInboundSignalMessage { sendTimestamp: number; avgLatency: number; isComplete?: true; - data: GeneralDatastoreMessageContent & Partial; + data: DatastoreMessageContent; }; } @@ -81,8 +82,9 @@ function isPresenceMessage( * @internal */ export interface PresenceDatastoreManager { + joinSession(clientId: ClientConnectionId): void; getWorkspace( - internalWorkspaceAddress: string, + internalWorkspaceAddress: InternalWorkspaceAddress, requestedContent: TSchema, ): PresenceStates; processSignal(message: IExtensionMessage, local: boolean): void; @@ -92,9 +94,7 @@ export interface PresenceDatastoreManager { * Manages singleton datastore for all Presence. */ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { - private readonly datastore: PresenceDatastore = { - "system:presence": { clientToSessionId: {} }, - }; + private readonly datastore: PresenceDatastore; private averageLatency = 0; private returnedMessages = 0; private refreshBroadcastRequested = false; @@ -104,29 +104,17 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { public constructor( private readonly clientSessionId: ClientSessionId, private readonly runtime: IEphemeralRuntime, - private readonly presence: PresenceManagerInternal, + private readonly lookupClient: (clientId: ClientSessionId) => ISessionClient, + private readonly logger: ITelemetryLoggerExt | undefined, + systemWorkspaceDatastore: SystemWorkspaceDatastore, + systemWorkspace: PresenceStatesEntry, ) { - runtime.on("connected", this.onConnect.bind(this)); - - // Check if already connected at the time of construction. - // If constructed during data store load, the runtime may already be connected - // and the "connected" event will be raised during completion. With construction - // delayed we expect that "connected" event has passed. - // Note: In some manual testing, this does not appear to be enough to - // always trigger an initial connect. - const clientId = runtime.clientId; - if (clientId !== undefined && runtime.connected) { - this.onConnect(clientId); - } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + this.datastore = { "system:presence": systemWorkspaceDatastore } as PresenceDatastore; + this.workspaces.set("system:presence", systemWorkspace); } - private onConnect(clientId: ClientConnectionId): void { - this.datastore["system:presence"].clientToSessionId[clientId] = { - rev: 0, - timestamp: Date.now(), - value: this.clientSessionId, - }; - + public joinSession(clientId: ClientConnectionId): void { // Broadcast join message to all clients const updateProviders = [...this.runtime.getQuorum().getMembers().keys()].filter( (quorumClientId) => quorumClientId !== clientId, @@ -145,7 +133,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { } public getWorkspace( - internalWorkspaceAddress: string, + internalWorkspaceAddress: InternalWorkspaceAddress, requestedContent: TSchema, ): PresenceStates { const existing = this.workspaces.get(internalWorkspaceAddress); @@ -167,12 +155,26 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { return; } - const updates: GeneralDatastoreMessageContent[string] = {}; + const clientConnectionId = this.runtime.clientId; + assert(clientConnectionId !== undefined, "Client connected without clientId"); + const currentClientToSessionValueState = + this.datastore["system:presence"].clientToSessionId[clientConnectionId]; + + const updates: GeneralDatastoreMessageContent[InternalWorkspaceAddress] = {}; for (const [key, value] of Object.entries(states)) { updates[key] = { [this.clientSessionId]: value }; } this.localUpdate( { + // Always send current connection mapping for some resiliency against + // lost signals. This ensures that client session id found in `updates` + // (which is this client's client session id) is always represented in + // system workspace of recipient clients. + "system:presence": { + clientToSessionId: { + [clientConnectionId]: { ...currentClientToSessionValueState }, + }, + }, [internalWorkspaceAddress]: updates, }, forceBroadcast, @@ -182,7 +184,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { const entry = createPresenceStates( { clientSessionId: this.clientSessionId, - lookupClient: this.presence.getAttendee.bind(this.presence), + lookupClient: this.lookupClient, localUpdate, }, workspaceDatastore, @@ -193,10 +195,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { return entry.public; } - private localUpdate( - data: GeneralDatastoreMessageContent & Partial, - _forceBroadcast: boolean, - ): void { + private localUpdate(data: DatastoreMessageContent, _forceBroadcast: boolean): void { const content = { sendTimestamp: Date.now(), avgLatency: this.averageLatency, @@ -304,7 +303,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { if (updateProviders.includes(clientId)) { // Send all current state to the new client this.broadcastAllKnownState(); - this.presence.mc?.logger.sendTelemetryEvent({ + this.logger?.sendTelemetryEvent({ eventName: "JoinResponse", details: { type: "broadcastAll", @@ -342,7 +341,7 @@ export class PresenceDatastoreManagerImpl implements PresenceDatastoreManager { // If not connected, nothing we can do. if (this.refreshBroadcastRequested && this.runtime.connected) { this.broadcastAllKnownState(); - this.presence.mc?.logger.sendTelemetryEvent({ + this.logger?.sendTelemetryEvent({ eventName: "JoinResponse", details: { type: "broadcastAll", diff --git a/packages/framework/presence/src/presenceManager.ts b/packages/framework/presence/src/presenceManager.ts index 4eddbcb839b3..e5061b1e55f2 100644 --- a/packages/framework/presence/src/presenceManager.ts +++ b/packages/framework/presence/src/presenceManager.ts @@ -4,11 +4,14 @@ */ import { createSessionId } from "@fluidframework/id-compressor/internal"; -import type { MonitoringContext } from "@fluidframework/telemetry-utils/internal"; +import type { + ITelemetryLoggerExt, + MonitoringContext, +} from "@fluidframework/telemetry-utils/internal"; import { createChildMonitoringContext } from "@fluidframework/telemetry-utils/internal"; import type { ClientConnectionId } from "./baseTypes.js"; -import type { IEphemeralRuntime, PresenceManagerInternal } from "./internalTypes.js"; +import type { IEphemeralRuntime } from "./internalTypes.js"; import type { ClientSessionId, IPresence, @@ -17,6 +20,8 @@ import type { } from "./presence.js"; import type { PresenceDatastoreManager } from "./presenceDatastoreManager.js"; import { PresenceDatastoreManagerImpl } from "./presenceDatastoreManager.js"; +import type { SystemWorkspace, SystemWorkspaceDatastore } from "./systemWorkspace.js"; +import { createSystemWorkspace } from "./systemWorkspace.js"; import type { PresenceStates, PresenceWorkspaceAddress, @@ -27,6 +32,7 @@ import type { IContainerExtension, IExtensionMessage, } from "@fluid-experimental/presence/internal/container-definitions/internal"; +import type { IEmitter } from "@fluid-experimental/presence/internal/events"; import { createEmitter } from "@fluid-experimental/presence/internal/events"; /** @@ -41,78 +47,57 @@ export type PresenceExtensionInterface = Required< /** * The Presence manager */ -class PresenceManager - implements IPresence, PresenceExtensionInterface, PresenceManagerInternal -{ +class PresenceManager implements IPresence, PresenceExtensionInterface { private readonly datastoreManager: PresenceDatastoreManager; - private readonly selfAttendee: ISessionClient; - private readonly attendees = new Map(); + private readonly systemWorkspace: SystemWorkspace; - public readonly mc: MonitoringContext | undefined = undefined; + public readonly events = createEmitter(); - public constructor(runtime: IEphemeralRuntime, clientSessionId: ClientSessionId) { - this.selfAttendee = { - sessionId: clientSessionId, - currentConnectionId: () => { - throw new Error("Client has never been connected"); - }, - }; - this.attendees.set(clientSessionId, this.selfAttendee); + private readonly mc: MonitoringContext | undefined = undefined; + public constructor(runtime: IEphemeralRuntime, clientSessionId: ClientSessionId) { const logger = runtime.logger; if (logger) { this.mc = createChildMonitoringContext({ logger, namespace: "Presence" }); this.mc.logger.sendTelemetryEvent({ eventName: "PresenceInstantiated" }); } - // If already connected (now or in the past), populate self and attendees. - const originalClientId = runtime.clientId; - if (originalClientId !== undefined) { - this.selfAttendee.currentConnectionId = () => originalClientId; - this.attendees.set(originalClientId, this.selfAttendee); - } - - // Watch for connected event that will produce new (or first) clientId. - // This event is added before instantiating the datastore manager so - // that self can be given a proper clientId before datastore manager - // might possibly try to use it. (Datastore manager is expected to - // use connected clientId more directly and no order dependence should - // be relied upon, but helps with debugging consistency.) - runtime.on("connected", (clientId: ClientConnectionId) => { - this.selfAttendee.currentConnectionId = () => clientId; - this.attendees.set(clientId, this.selfAttendee); - }); - - this.datastoreManager = new PresenceDatastoreManagerImpl( - this.selfAttendee.sessionId, + [this.datastoreManager, this.systemWorkspace] = setupSubComponents( + clientSessionId, runtime, - this, + this.events, + this.mc?.logger, ); + + runtime.on("connected", this.onConnect.bind(this)); + + // Check if already connected at the time of construction. + // If constructed during data store load, the runtime may already be connected + // and the "connected" event will be raised during completion. With construction + // delayed we expect that "connected" event has passed. + // Note: In some manual testing, this does not appear to be enough to + // always trigger an initial connect. + const clientId = runtime.clientId; + if (clientId !== undefined && runtime.connected) { + this.onConnect(clientId); + } } - public readonly events = createEmitter(); + private onConnect(clientConnectionId: ClientConnectionId): void { + this.systemWorkspace.onConnectionAdded(clientConnectionId); + this.datastoreManager.joinSession(clientConnectionId); + } public getAttendees(): ReadonlySet { - return new Set(this.attendees.values()); + return this.systemWorkspace.getAttendees(); } public getAttendee(clientId: ClientConnectionId | ClientSessionId): ISessionClient { - const attendee = this.attendees.get(clientId); - if (attendee) { - return attendee; - } - // This is a major hack to enable basic operation. - // Missing attendees should be rejected. - const newAttendee = { - sessionId: clientId as ClientSessionId, - currentConnectionId: () => clientId, - } satisfies ISessionClient; - this.attendees.set(clientId, newAttendee); - return newAttendee; + return this.systemWorkspace.getAttendee(clientId); } public getMyself(): ISessionClient { - return this.selfAttendee; + return this.systemWorkspace.getMyself(); } public getStates( @@ -141,6 +126,41 @@ class PresenceManager } } +/** + * Helper for Presence Manager setup + * + * Presence Manager is outermost layer of the presence system and has two main + * sub-components: + * 1. PresenceDatastoreManager: Manages the unified general data for states and + * registry for workspaces. + * 2. SystemWorkspace: Custom internal workspace for system states including + * attendee management. It is registered with the PresenceDatastoreManager. + */ +function setupSubComponents( + clientSessionId: ClientSessionId, + runtime: IEphemeralRuntime, + events: IEmitter, + logger: ITelemetryLoggerExt | undefined, +): [PresenceDatastoreManager, SystemWorkspace] { + const systemWorkspaceDatastore: SystemWorkspaceDatastore = { + clientToSessionId: {}, + }; + const systemWorkspaceConfig = createSystemWorkspace( + clientSessionId, + systemWorkspaceDatastore, + events, + ); + const datastoreManager = new PresenceDatastoreManagerImpl( + clientSessionId, + runtime, + systemWorkspaceConfig.workspace.getAttendee.bind(systemWorkspaceConfig.workspace), + logger, + systemWorkspaceDatastore, + systemWorkspaceConfig.statesEntry, + ); + return [datastoreManager, systemWorkspaceConfig.workspace]; +} + /** * Instantiates Presence Manager * diff --git a/packages/framework/presence/src/systemWorkspace.ts b/packages/framework/presence/src/systemWorkspace.ts new file mode 100644 index 000000000000..9c79b214950d --- /dev/null +++ b/packages/framework/presence/src/systemWorkspace.ts @@ -0,0 +1,226 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { assert } from "@fluidframework/core-utils/internal"; + +import type { ClientConnectionId } from "./baseTypes.js"; +import type { InternalTypes } from "./exposedInternalTypes.js"; +import type { + ClientSessionId, + IPresence, + ISessionClient, + PresenceEvents, +} from "./presence.js"; +import type { PresenceStatesInternal } from "./presenceStates.js"; +import type { PresenceStates, PresenceStatesSchema } from "./types.js"; + +import type { IEmitter } from "@fluid-experimental/presence/internal/events"; + +/** + * The system workspace's datastore structure. + * + * @internal + */ +export interface SystemWorkspaceDatastore { + clientToSessionId: { + [ConnectionId: ClientConnectionId]: InternalTypes.ValueRequiredState; + }; +} + +/** + * There is no implementation class for this interface. + * It is a simple structure. Most complicated aspect is that + * `currentConnectionId()` member is replaced with a new + * function when a more recent connection is added. + * + * See {@link SystemWorkspaceImpl.ensureAttendee}. + */ +interface SessionClient extends ISessionClient { + /** + * Order is used to track the most recent client connection + * during a session. + */ + order: number; +} + +/** + * @internal + */ +export interface SystemWorkspace + // Portion of IPresence that is handled by SystemWorkspace along with + // responsiblity for emitting "attendeeJoined" events. + extends Pick { + /** + * Must be called when the current client acquires a new connection. + * + * @param clientConnectionId - The new client connection id. + */ + onConnectionAdded(clientConnectionId: ClientConnectionId): void; +} + +class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { + private readonly selfAttendee: SessionClient; + /** + * `attendees` is this client's understanding of the attendees in the + * session. The map covers entries for both session ids and connection + * ids, which are never expected to collide, but if they did for same + * client that would be fine. + * An entry is for session id if the value's `sessionId` matches the key. + */ + private readonly attendees = new Map(); + + public constructor( + clientSessionId: ClientSessionId, + private readonly datastore: SystemWorkspaceDatastore, + public readonly events: IEmitter>, + ) { + this.selfAttendee = { + sessionId: clientSessionId, + order: 0, + currentConnectionId: () => { + throw new Error("Client has never been connected"); + }, + }; + this.attendees.set(clientSessionId, this.selfAttendee); + } + + public ensureContent( + _content: TSchemaAdditional, + ): never { + throw new Error("Method not implemented."); + } + + public processUpdate( + _received: number, + _timeModifier: number, + remoteDatastore: { + clientToSessionId: { + [ + ConnectionId: ClientConnectionId + ]: InternalTypes.ValueRequiredState & { + ignoreUnmonitored?: true; + }; + }; + }, + ): void { + const postUpdateActions: (() => void)[] = []; + for (const [clientConnectionId, value] of Object.entries( + remoteDatastore.clientToSessionId, + )) { + const clientSessionId = value.value; + const { attendee, isNew } = this.ensureAttendee( + clientSessionId, + clientConnectionId, + /* order */ value.rev, + ); + if (isNew) { + postUpdateActions.push(() => this.events.emit("attendeeJoined", attendee)); + } + const knownSessionId: InternalTypes.ValueRequiredState | undefined = + this.datastore.clientToSessionId[clientConnectionId]; + if (knownSessionId === undefined) { + this.datastore.clientToSessionId[clientConnectionId] = value; + } else { + assert(knownSessionId.value === value.value, "Mismatched SessionId"); + } + } + // TODO: reorganize processUpdate and caller to process actions after all updates are processed. + for (const action of postUpdateActions) { + action(); + } + } + + public onConnectionAdded(clientConnectionId: ClientConnectionId): void { + this.datastore.clientToSessionId[clientConnectionId] = { + rev: this.selfAttendee.order++, + timestamp: Date.now(), + value: this.selfAttendee.sessionId, + }; + + this.selfAttendee.currentConnectionId = () => clientConnectionId; + this.attendees.set(clientConnectionId, this.selfAttendee); + } + + public getAttendees(): ReadonlySet { + return new Set(this.attendees.values()); + } + + public getAttendee(clientId: ClientConnectionId | ClientSessionId): ISessionClient { + const attendee = this.attendees.get(clientId); + if (attendee) { + return attendee; + } + + // TODO: Restore option to add attendee on demand to handle internal + // lookup cases that must come from internal data. + // There aren't any resiliency mechanisms in place to handle a missed + // ClientJoin right now. + throw new Error("Attendee not found"); + } + + public getMyself(): ISessionClient { + return this.selfAttendee; + } + + /** + * Make sure the given client session and connection id pair are represented + * in the attendee map. If not present, SessionClient is created and added + * to map. If present, make sure the current connection id is updated. + */ + private ensureAttendee( + clientSessionId: ClientSessionId, + clientConnectionId: ClientConnectionId, + order: number, + ): { attendee: SessionClient; isNew: boolean } { + const currentConnectionId = (): ClientConnectionId => clientConnectionId; + let attendee = this.attendees.get(clientSessionId); + let isNew = false; + if (attendee === undefined) { + // New attendee. Create SessionClient and add session id based + // entry to map. + attendee = { + sessionId: clientSessionId, + order, + currentConnectionId, + }; + this.attendees.set(clientSessionId, attendee); + isNew = true; + } else if (order > attendee.order) { + // The given association is newer than the one we have. + // Update the order and current connection id. + attendee.order = order; + attendee.currentConnectionId = currentConnectionId; + } + // Always update entry for the connection id. (Okay if already set.) + this.attendees.set(clientConnectionId, attendee); + return { attendee, isNew }; + } +} + +/** + * Instantiates the system workspace. + * + * @internal + */ +export function createSystemWorkspace( + clientSessionId: ClientSessionId, + datastore: SystemWorkspaceDatastore, + events: IEmitter>, +): { + workspace: SystemWorkspace; + statesEntry: { + internal: PresenceStatesInternal; + public: PresenceStates; + }; +} { + const workspace = new SystemWorkspaceImpl(clientSessionId, datastore, events); + return { + workspace, + statesEntry: { + internal: workspace, + public: undefined as unknown as PresenceStates, + }, + }; +} diff --git a/packages/framework/presence/src/test/presenceManager.spec.ts b/packages/framework/presence/src/test/presenceManager.spec.ts index 19dd468a2dc9..abb1c2e526cb 100644 --- a/packages/framework/presence/src/test/presenceManager.spec.ts +++ b/packages/framework/presence/src/test/presenceManager.spec.ts @@ -3,24 +3,42 @@ * Licensed under the MIT License. */ +import { strict as assert } from "node:assert"; + import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal"; +import type { SinonFakeTimers } from "sinon"; +import { useFakeTimers } from "sinon"; +import type { ISessionClient } from "../presence.js"; import { createPresenceManager } from "../presenceManager.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; -import { assertFinalExpectations } from "./testUtils.js"; +import { + assertFinalExpectations, + generateBasicClientJoin, + prepareConnectedPresence, +} from "./testUtils.js"; describe("Presence", () => { describe("PresenceManager", () => { let runtime: MockEphemeralRuntime; let logger: EventAndErrorTrackingLogger; + const initialTime = 1000; + let clock: SinonFakeTimers; + + before(async () => { + clock = useFakeTimers(); + }); beforeEach(() => { logger = new EventAndErrorTrackingLogger(); runtime = new MockEphemeralRuntime(logger); + clock.setSystemTime(initialTime); }); afterEach(function (done: Mocha.Done) { + clock.reset(); + // If the test passed so far, check final expectations. if (this.currentTest?.state === "passed") { assertFinalExpectations(runtime, logger); @@ -28,6 +46,10 @@ describe("Presence", () => { done(); }); + after(() => { + clock.restore(); + }); + it("can be created", () => { // Act & Verify (does not throw) createPresenceManager(runtime); @@ -43,5 +65,160 @@ describe("Presence", () => { // Verify assertFinalExpectations(runtime, logger); }); + + it("throws when unknown attendee is requested via `getAttendee`", () => { + // Setup + const presence = createPresenceManager(runtime); + + // Act & Verify + assert.throws(() => presence.getAttendee("unknown"), /Attendee not found/); + }); + + describe("when connected", () => { + let presence: ReturnType; + const afterCleanUp: (() => void)[] = []; + + beforeEach(() => { + presence = prepareConnectedPresence(runtime, "seassionId-2", "client2", clock, logger); + }); + + afterEach(() => { + for (const cleanUp of afterCleanUp) { + cleanUp(); + } + afterCleanUp.length = 0; + }); + + describe("attendee", () => { + const newAttendeeSessionId = "sessionId-4"; + const initialAttendeeConnectionId = "client4"; + let newAttendee: ISessionClient | undefined; + let initialAttendeeSignal: ReturnType; + + beforeEach(() => { + runtime.submitSignal = () => {}; + newAttendee = undefined; + afterCleanUp.push( + presence.events.on("attendeeJoined", (attendee) => { + assert(newAttendee === undefined, "Only one attendee should be announced"); + newAttendee = attendee; + }), + ); + + initialAttendeeSignal = generateBasicClientJoin(clock.now - 50, { + averageLatency: 50, + clientSessionId: newAttendeeSessionId, + clientConnectionId: initialAttendeeConnectionId, + updateProviders: ["client2"], + }); + }); + + it("is announced via `attendeeJoined` when new", () => { + // Act - simulate join message from client + presence.processSignal("", initialAttendeeSignal, false); + + // Verify + assert(newAttendee !== undefined, "No attendee was announced"); + assert.equal( + newAttendee.sessionId, + newAttendeeSessionId, + "Attendee has wrong session id", + ); + assert.equal( + newAttendee.currentConnectionId(), + initialAttendeeConnectionId, + "Attendee has wrong client connection id", + ); + }); + + describe("already known", () => { + beforeEach(() => { + // Setup - simulate join message from client + presence.processSignal("", initialAttendeeSignal, false); + assert(newAttendee !== undefined, "No attendee was announced in setup"); + }); + + for (const [desc, id] of [ + ["connection id", initialAttendeeConnectionId] as const, + ["session id", newAttendeeSessionId] as const, + ]) { + it(`is available from \`getAttendee\` by ${desc}`, () => { + // Act + const attendee = presence.getAttendee(id); + // Verify + assert.equal(attendee, newAttendee, "getAttendee returned wrong attendee"); + }); + } + + it("is available from `getAttendees`", () => { + // Setup + assert(newAttendee !== undefined, "No attendee was set in beforeEach"); + + // Act + const attendees = presence.getAttendees(); + assert(attendees.has(newAttendee), "getAttendees set does not contain attendee"); + }); + + it("is NOT announced when rejoined with same connection (duplicate signal)", () => { + clock.tick(10); + + // Act & Verify - simulate duplicate join message from client + presence.processSignal("", initialAttendeeSignal, false); + }); + + it("is NOT announced when rejoined with different connection and current information is updated", () => { + // Setup + assert(newAttendee !== undefined, "No attendee was set in beforeEach"); + + const updatedClientConnectionId = "client5"; + clock.tick(20); + const rejoinedAttendeeSignal = generateBasicClientJoin(clock.now - 20, { + averageLatency: 20, + clientSessionId: newAttendeeSessionId, // Same session id + clientConnectionId: updatedClientConnectionId, // Different connection id + connectionOrder: 1, + updateProviders: ["client2"], + }); + rejoinedAttendeeSignal.content.data["system:presence"].clientToSessionId[ + initialAttendeeConnectionId + ] = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + initialAttendeeSignal.content.data["system:presence"].clientToSessionId[ + initialAttendeeConnectionId + ]!; + + // Act - simulate new join message from same client (without disconnect) + presence.processSignal("", rejoinedAttendeeSignal, false); + + // Verify + // Session id is unchanged + assert.equal( + newAttendee.sessionId, + newAttendeeSessionId, + "Attendee has wrong session id", + ); + // Current connection id is updated + assert( + newAttendee.currentConnectionId() === updatedClientConnectionId, + "Attendee does not have updated client connection id", + ); + // Attendee is available via new connection id + const attendeeViaUpdatedId = presence.getAttendee(updatedClientConnectionId); + assert.equal( + attendeeViaUpdatedId, + newAttendee, + "getAttendee returned wrong attendee for updated connection id", + ); + // Attendee is available via old connection id + const attendeeViaOriginalId = presence.getAttendee(initialAttendeeConnectionId); + assert.equal( + attendeeViaOriginalId, + newAttendee, + "getAttendee returned wrong attendee for original connection id", + ); + }); + }); + }); + }); }); }); diff --git a/packages/framework/presence/src/test/testUtils.ts b/packages/framework/presence/src/test/testUtils.ts index ab04b431bdbc..db6a9ccc5961 100644 --- a/packages/framework/presence/src/test/testUtils.ts +++ b/packages/framework/presence/src/test/testUtils.ts @@ -12,26 +12,37 @@ import { createPresenceManager } from "../presenceManager.js"; import type { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; import type { ClientConnectionId, ClientSessionId } from "@fluid-experimental/presence"; +import type { IExtensionMessage } from "@fluid-experimental/presence/internal/container-definitions/internal"; /** * Generates expected join signal for a client that was initialized while connected. */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type -export function craftInitializationClientJoin( +export function generateBasicClientJoin( fixedTime: number, - clientSessionId: string = "seassionId-2", - clientConnectionId: ClientConnectionId = "client2", - updateProviders: string[] = ["client0", "client1", "client3"], + { + clientSessionId = "seassionId-2", + clientConnectionId = "client2", + updateProviders = ["client0", "client1", "client3"], + connectionOrder = 0, + averageLatency = 0, + }: { + clientSessionId?: string; + clientConnectionId?: ClientConnectionId; + updateProviders?: string[]; + connectionOrder?: number; + averageLatency?: number; + }, ) { return { type: "Pres:ClientJoin", content: { - "avgLatency": 0, + "avgLatency": averageLatency, "data": { "system:presence": { "clientToSessionId": { [clientConnectionId]: { - "rev": 0, + "rev": connectionOrder, "timestamp": fixedTime, "value": clientSessionId, }, @@ -41,7 +52,8 @@ export function craftInitializationClientJoin( "sendTimestamp": fixedTime, updateProviders, }, - }; + clientId: clientConnectionId, + } satisfies IExtensionMessage<"Pres:ClientJoin">; } /** @@ -76,12 +88,11 @@ export function prepareConnectedPresence( quorumClientIds.length = 3; } - const expectedClientJoin = craftInitializationClientJoin( - clock.now, + const expectedClientJoin = generateBasicClientJoin(clock.now, { clientSessionId, clientConnectionId, - quorumClientIds, - ); + updateProviders: quorumClientIds, + }); runtime.signalsExpected.push([expectedClientJoin.type, expectedClientJoin.content]); const presence = createPresenceManager(runtime, clientSessionId as ClientSessionId);