Skip to content

Commit

Permalink
feat(client-presence): attendeeDisconnected and SessionClientStatus s…
Browse files Browse the repository at this point in the history
…upport (#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.
  • Loading branch information
WillieHabi authored Oct 24, 2024
1 parent 7a8c8d0 commit aaece3f
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
12 changes: 11 additions & 1 deletion packages/framework/presence/api-report/presence.alpha.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export interface IPresence {

// @alpha @sealed
export interface ISessionClient<SpecificSessionClientId extends ClientSessionId = ClientSessionId> {
currentConnectionId(): ClientConnectionId;
connectionId(): ClientConnectionId;
getStatus(): SessionClientStatus;
// (undocumented)
readonly sessionId: SpecificSessionClientId;
}
Expand Down Expand Up @@ -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<K extends string | number, V> {
clear(): void;
Expand Down
2 changes: 1 addition & 1 deletion packages/framework/presence/src/baseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/framework/presence/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type {
IPresence,
ISessionClient,
PresenceEvents,
SessionClientStatus,
} from "./presence.js";

export { acquirePresence } from "./experimentalAccess.js";
Expand Down
30 changes: 29 additions & 1 deletion packages/framework/presence/src/presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down
22 changes: 20 additions & 2 deletions packages/framework/presence/src/presenceManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,24 @@ export type PresenceExtensionInterface = Required<
Pick<IContainerExtension<never>, "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;

Expand Down Expand Up @@ -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<ISessionClient> {
return this.systemWorkspace.getAttendees();
}
Expand Down Expand Up @@ -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);
}
52 changes: 40 additions & 12 deletions packages/framework/presence/src/systemWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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}.
Expand All @@ -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 {
Expand All @@ -74,14 +82,17 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
public constructor(
clientSessionId: ClientSessionId,
private readonly datastore: SystemWorkspaceDatastore,
public readonly events: IEmitter<Pick<PresenceEvents, "attendeeJoined">>,
public readonly events: IEmitter<
Pick<PresenceEvents, "attendeeJoined" | "attendeeDisconnected">
>,
) {
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);
}
Expand Down Expand Up @@ -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<ISessionClient> {
return new Set(this.attendees.values());
}
Expand Down Expand Up @@ -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) {
Expand All @@ -183,15 +210,16 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
attendee = {
sessionId: clientSessionId,
order,
currentConnectionId,
connectionId,
getStatus: () => SessionClientStatus.Connected,
};
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;
attendee.connectionId = connectionId;
}
// Always update entry for the connection id. (Okay if already set.)
this.attendees.set(clientConnectionId, attendee);
Expand Down
58 changes: 55 additions & 3 deletions packages/framework/presence/src/test/presenceManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit aaece3f

Please sign in to comment.