From aaece3fc9a90e92596bc4f8d4d3ef8148a5e24a3 Mon Sep 17 00:00:00 2001 From: WillieHabi <143546745+WillieHabi@users.noreply.github.com> Date: Wed, 23 Oct 2024 21:06:34 -0700 Subject: [PATCH] feat(client-presence): attendeeDisconnected and SessionClientStatus support (#22833) ## Description This PR adds support for attendeeDisconnected events by using Audience to monitor disconnected clients and announcing them. To represent a connection status for ISessionClient's, we also now added SessionClientStatus. --- .../external-controller/src/view.ts | 2 +- .../presence/api-report/presence.alpha.api.md | 12 +++- packages/framework/presence/src/baseTypes.ts | 2 +- .../src/datastorePresenceManagerFactory.ts | 3 + packages/framework/presence/src/index.ts | 1 + packages/framework/presence/src/presence.ts | 30 +++++++++- .../framework/presence/src/presenceManager.ts | 22 ++++++- .../framework/presence/src/systemWorkspace.ts | 52 +++++++++++++---- .../presence/src/test/presenceManager.spec.ts | 58 ++++++++++++++++++- 9 files changed, 161 insertions(+), 21 deletions(-) diff --git a/examples/service-clients/azure-client/external-controller/src/view.ts b/examples/service-clients/azure-client/external-controller/src/view.ts index 04eaaa200ee9..8a1938ddf9c8 100644 --- a/examples/service-clients/azure-client/external-controller/src/view.ts +++ b/examples/service-clients/azure-client/external-controller/src/view.ts @@ -167,7 +167,7 @@ function makePresenceView( logContentDiv.style.border = "1px solid black"; if (audience !== undefined) { presenceConfig.presence.events.on("attendeeJoined", (attendee) => { - const name = audience.getMembers().get(attendee.currentConnectionId())?.name; + const name = audience.getMembers().get(attendee.connectionId())?.name; const update = `client ${name === undefined ? "(unnamed)" : `named ${name}`} with id ${attendee.sessionId} joined`; addLogEntry(logContentDiv, update); }); diff --git a/packages/framework/presence/api-report/presence.alpha.api.md b/packages/framework/presence/api-report/presence.alpha.api.md index 73bc1226dc98..46d10df4ce81 100644 --- a/packages/framework/presence/api-report/presence.alpha.api.md +++ b/packages/framework/presence/api-report/presence.alpha.api.md @@ -37,7 +37,8 @@ export interface IPresence { // @alpha @sealed export interface ISessionClient { - currentConnectionId(): ClientConnectionId; + connectionId(): ClientConnectionId; + getStatus(): SessionClientStatus; // (undocumented) readonly sessionId: SpecificSessionClientId; } @@ -214,6 +215,15 @@ export interface PresenceStatesSchema { // @alpha export type PresenceWorkspaceAddress = `${string}:${string}`; +// @alpha +export const SessionClientStatus: { + readonly Connected: "Connected"; + readonly Disconnected: "Disconnected"; +}; + +// @alpha +export type SessionClientStatus = (typeof SessionClientStatus)[keyof typeof SessionClientStatus]; + // @alpha @sealed export interface ValueMap { clear(): void; diff --git a/packages/framework/presence/src/baseTypes.ts b/packages/framework/presence/src/baseTypes.ts index 95336da36d59..3f2586f3d6af 100644 --- a/packages/framework/presence/src/baseTypes.ts +++ b/packages/framework/presence/src/baseTypes.ts @@ -10,7 +10,7 @@ * Each client connection is given a unique identifier for the duration of the * connection. If a client disconnects and reconnects, it will be given a new * identifier. Prefer use of {@link ISessionClient} as a way to identify clients - * in a session. {@link ISessionClient.currentConnectionId} will provide the current + * in a session. {@link ISessionClient.connectionId} will provide the current * connection identifier for a logical session client. * * @privateRemarks diff --git a/packages/framework/presence/src/datastorePresenceManagerFactory.ts b/packages/framework/presence/src/datastorePresenceManagerFactory.ts index 2845dc6082d3..d310cf4ea1c7 100644 --- a/packages/framework/presence/src/datastorePresenceManagerFactory.ts +++ b/packages/framework/presence/src/datastorePresenceManagerFactory.ts @@ -43,6 +43,9 @@ class PresenceManagerDataObject extends LoadableFluidObject { assertSignalMessageIsValid(message); manager.processSignal("", message, local); }); + this.runtime.getAudience().on("removeMember", (clientId: string) => { + manager.removeClientConnectionId(clientId); + }); this._presenceManager = manager; } return this._presenceManager; diff --git a/packages/framework/presence/src/index.ts b/packages/framework/presence/src/index.ts index e5ca80920656..59dbe9fee352 100644 --- a/packages/framework/presence/src/index.ts +++ b/packages/framework/presence/src/index.ts @@ -43,6 +43,7 @@ export type { IPresence, ISessionClient, PresenceEvents, + SessionClientStatus, } from "./presence.js"; export { acquirePresence } from "./experimentalAccess.js"; diff --git a/packages/framework/presence/src/presence.ts b/packages/framework/presence/src/presence.ts index a75fc1a130f3..7e89213bb26c 100644 --- a/packages/framework/presence/src/presence.ts +++ b/packages/framework/presence/src/presence.ts @@ -30,6 +30,24 @@ import type { ISubscribable } from "@fluid-experimental/presence/internal/events */ export type ClientSessionId = SessionId & { readonly ClientSessionId: "ClientSessionId" }; +/** + * The connection status of the {@link ISessionClient}. + * + * @alpha + */ +export const SessionClientStatus = { + Connected: "Connected", + Disconnected: "Disconnected", +} as const; + +/** + * Type for the connection status of the {@link ISessionClient}. + * + * @alpha + */ +export type SessionClientStatus = + (typeof SessionClientStatus)[keyof typeof SessionClientStatus]; + /** * A client within a Fluid session (period of container connectivity to service). * @@ -60,8 +78,18 @@ export interface ISessionClient< * * @remarks * Connection id will change on reconnect. + * + * If {@link ISessionClient.getStatus} is {@link (SessionClientStatus:variable).Disconnected}, this will represent the last known connection id. + */ + connectionId(): ClientConnectionId; + + /** + * Get status of session client. + * + * @returns Status of session client. + * */ - currentConnectionId(): ClientConnectionId; + getStatus(): SessionClientStatus; } /** diff --git a/packages/framework/presence/src/presenceManager.ts b/packages/framework/presence/src/presenceManager.ts index e5061b1e55f2..4cd2938a2715 100644 --- a/packages/framework/presence/src/presenceManager.ts +++ b/packages/framework/presence/src/presenceManager.ts @@ -44,10 +44,24 @@ export type PresenceExtensionInterface = Required< Pick, "processSignal"> >; +/** + * Internal managment of client connection ids. + */ +export interface ClientConnectionManager { + /** + * Remove the current client connection id from the corresponding disconnected attendee. + * + * @param clientConnectionId - The current client connection id to be removed. + */ + removeClientConnectionId(clientConnectionId: ClientConnectionId): void; +} + /** * The Presence manager */ -class PresenceManager implements IPresence, PresenceExtensionInterface { +class PresenceManager + implements IPresence, PresenceExtensionInterface, ClientConnectionManager +{ private readonly datastoreManager: PresenceDatastoreManager; private readonly systemWorkspace: SystemWorkspace; @@ -88,6 +102,10 @@ class PresenceManager implements IPresence, PresenceExtensionInterface { this.datastoreManager.joinSession(clientConnectionId); } + public removeClientConnectionId(clientConnectionId: ClientConnectionId): void { + this.systemWorkspace.removeClientConnectionId(clientConnectionId); + } + public getAttendees(): ReadonlySet { return this.systemWorkspace.getAttendees(); } @@ -169,6 +187,6 @@ function setupSubComponents( export function createPresenceManager( runtime: IEphemeralRuntime, clientSessionId: ClientSessionId = createSessionId() as ClientSessionId, -): IPresence & PresenceExtensionInterface { +): IPresence & PresenceExtensionInterface & ClientConnectionManager { return new PresenceManager(runtime, clientSessionId); } diff --git a/packages/framework/presence/src/systemWorkspace.ts b/packages/framework/presence/src/systemWorkspace.ts index 959ac99c7333..32d63aea9d92 100644 --- a/packages/framework/presence/src/systemWorkspace.ts +++ b/packages/framework/presence/src/systemWorkspace.ts @@ -7,11 +7,12 @@ 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, +import { + SessionClientStatus, + type ClientSessionId, + type IPresence, + type ISessionClient, + type PresenceEvents, } from "./presence.js"; import type { PresenceStatesInternal } from "./presenceStates.js"; import type { PresenceStates, PresenceStatesSchema } from "./types.js"; @@ -32,7 +33,7 @@ export interface SystemWorkspaceDatastore { /** * 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 + * `connectionId()` member is replaced with a new * function when a more recent connection is added. * * See {@link SystemWorkspaceImpl.ensureAttendee}. @@ -58,6 +59,13 @@ export interface SystemWorkspace * @param clientConnectionId - The new client connection id. */ onConnectionAdded(clientConnectionId: ClientConnectionId): void; + + /** + * Removes the client connection id from the system workspace. + * + * @param clientConnectionId - The client connection id to remove. + */ + removeClientConnectionId(clientConnectionId: ClientConnectionId): void; } class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { @@ -74,14 +82,17 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { public constructor( clientSessionId: ClientSessionId, private readonly datastore: SystemWorkspaceDatastore, - public readonly events: IEmitter>, + public readonly events: IEmitter< + Pick + >, ) { this.selfAttendee = { sessionId: clientSessionId, order: 0, - currentConnectionId: () => { + connectionId: () => { throw new Error("Client has never been connected"); }, + getStatus: () => SessionClientStatus.Disconnected, }; this.attendees.set(clientSessionId, this.selfAttendee); } @@ -139,10 +150,26 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { value: this.selfAttendee.sessionId, }; - this.selfAttendee.currentConnectionId = () => clientConnectionId; + this.selfAttendee.connectionId = () => clientConnectionId; + this.selfAttendee.getStatus = () => SessionClientStatus.Connected; this.attendees.set(clientConnectionId, this.selfAttendee); } + public removeClientConnectionId(clientConnectionId: ClientConnectionId): void { + const attendee = this.attendees.get(clientConnectionId); + if (!attendee) { + return; + } + + // If the last known connectionID is different from the connection id being removed, the attendee has reconnected, + // therefore we should not change the attendee connection status or emit a disconnect event. + const attendeeReconnected = attendee.connectionId() !== clientConnectionId; + if (!attendeeReconnected) { + attendee.getStatus = () => SessionClientStatus.Disconnected; + this.events.emit("attendeeDisconnected", attendee); + } + } + public getAttendees(): ReadonlySet { return new Set(this.attendees.values()); } @@ -174,7 +201,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { clientConnectionId: ClientConnectionId, order: number, ): { attendee: SessionClient; isNew: boolean } { - const currentConnectionId = (): ClientConnectionId => clientConnectionId; + const connectionId = (): ClientConnectionId => clientConnectionId; let attendee = this.attendees.get(clientSessionId); let isNew = false; if (attendee === undefined) { @@ -183,7 +210,8 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { attendee = { sessionId: clientSessionId, order, - currentConnectionId, + connectionId, + getStatus: () => SessionClientStatus.Connected, }; this.attendees.set(clientSessionId, attendee); isNew = true; @@ -191,7 +219,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace { // The given association is newer than the one we have. // Update the order and current connection id. attendee.order = order; - attendee.currentConnectionId = currentConnectionId; + attendee.connectionId = connectionId; } // Always update entry for the connection id. (Okay if already set.) this.attendees.set(clientConnectionId, attendee); diff --git a/packages/framework/presence/src/test/presenceManager.spec.ts b/packages/framework/presence/src/test/presenceManager.spec.ts index abb1c2e526cb..ccf5821761cf 100644 --- a/packages/framework/presence/src/test/presenceManager.spec.ts +++ b/packages/framework/presence/src/test/presenceManager.spec.ts @@ -9,7 +9,7 @@ import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal import type { SinonFakeTimers } from "sinon"; import { useFakeTimers } from "sinon"; -import type { ISessionClient } from "../presence.js"; +import { SessionClientStatus, type ISessionClient } from "../presence.js"; import { createPresenceManager } from "../presenceManager.js"; import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js"; @@ -125,12 +125,64 @@ describe("Presence", () => { "Attendee has wrong session id", ); assert.equal( - newAttendee.currentConnectionId(), + newAttendee.connectionId(), initialAttendeeConnectionId, "Attendee has wrong client connection id", ); }); + describe("disconnects", () => { + let disconnectedAttendee: ISessionClient | undefined; + beforeEach(() => { + disconnectedAttendee = undefined; + afterCleanUp.push( + presence.events.on("attendeeDisconnected", (attendee) => { + assert( + disconnectedAttendee === undefined, + "Only one attendee should be disconnected", + ); + disconnectedAttendee = attendee; + }), + ); + // Setup - simulate join message from client + presence.processSignal("", initialAttendeeSignal, false); + + // Act - remove client connection id + presence.removeClientConnectionId(initialAttendeeConnectionId); + }); + + it("is announced via `attendeeDisconnected` when audience member leaves", () => { + // Verify + assert( + disconnectedAttendee !== undefined, + "No attendee was disconnected in beforeEach", + ); + assert.equal( + disconnectedAttendee.sessionId, + newAttendeeSessionId, + "Disconnected attendee has wrong session id", + ); + assert.equal( + disconnectedAttendee.connectionId(), + initialAttendeeConnectionId, + "Disconnected attendee has wrong client connection id", + ); + }); + + it("changes the session client status to `Disconnected`", () => { + // Verify + assert( + disconnectedAttendee !== undefined, + "No attendee was disconnected in beforeEach", + ); + assert.equal( + disconnectedAttendee.getStatus(), + SessionClientStatus.Disconnected, + "Disconnected attendee has wrong status", + ); + }); + }); + describe("already known", () => { beforeEach(() => { // Setup - simulate join message from client @@ -199,7 +251,7 @@ describe("Presence", () => { ); // Current connection id is updated assert( - newAttendee.currentConnectionId() === updatedClientConnectionId, + newAttendee.connectionId() === updatedClientConnectionId, "Attendee does not have updated client connection id", ); // Attendee is available via new connection id