Skip to content

Commit aaece3f

Browse files
authored
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.
1 parent 7a8c8d0 commit aaece3f

File tree

9 files changed

+161
-21
lines changed

9 files changed

+161
-21
lines changed

examples/service-clients/azure-client/external-controller/src/view.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ function makePresenceView(
167167
logContentDiv.style.border = "1px solid black";
168168
if (audience !== undefined) {
169169
presenceConfig.presence.events.on("attendeeJoined", (attendee) => {
170-
const name = audience.getMembers().get(attendee.currentConnectionId())?.name;
170+
const name = audience.getMembers().get(attendee.connectionId())?.name;
171171
const update = `client ${name === undefined ? "(unnamed)" : `named ${name}`} with id ${attendee.sessionId} joined`;
172172
addLogEntry(logContentDiv, update);
173173
});

packages/framework/presence/api-report/presence.alpha.api.md

+11-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export interface IPresence {
3737

3838
// @alpha @sealed
3939
export interface ISessionClient<SpecificSessionClientId extends ClientSessionId = ClientSessionId> {
40-
currentConnectionId(): ClientConnectionId;
40+
connectionId(): ClientConnectionId;
41+
getStatus(): SessionClientStatus;
4142
// (undocumented)
4243
readonly sessionId: SpecificSessionClientId;
4344
}
@@ -214,6 +215,15 @@ export interface PresenceStatesSchema {
214215
// @alpha
215216
export type PresenceWorkspaceAddress = `${string}:${string}`;
216217

218+
// @alpha
219+
export const SessionClientStatus: {
220+
readonly Connected: "Connected";
221+
readonly Disconnected: "Disconnected";
222+
};
223+
224+
// @alpha
225+
export type SessionClientStatus = (typeof SessionClientStatus)[keyof typeof SessionClientStatus];
226+
217227
// @alpha @sealed
218228
export interface ValueMap<K extends string | number, V> {
219229
clear(): void;

packages/framework/presence/src/baseTypes.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* Each client connection is given a unique identifier for the duration of the
1111
* connection. If a client disconnects and reconnects, it will be given a new
1212
* identifier. Prefer use of {@link ISessionClient} as a way to identify clients
13-
* in a session. {@link ISessionClient.currentConnectionId} will provide the current
13+
* in a session. {@link ISessionClient.connectionId} will provide the current
1414
* connection identifier for a logical session client.
1515
*
1616
* @privateRemarks

packages/framework/presence/src/datastorePresenceManagerFactory.ts

+3
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ class PresenceManagerDataObject extends LoadableFluidObject {
4343
assertSignalMessageIsValid(message);
4444
manager.processSignal("", message, local);
4545
});
46+
this.runtime.getAudience().on("removeMember", (clientId: string) => {
47+
manager.removeClientConnectionId(clientId);
48+
});
4649
this._presenceManager = manager;
4750
}
4851
return this._presenceManager;

packages/framework/presence/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type {
4343
IPresence,
4444
ISessionClient,
4545
PresenceEvents,
46+
SessionClientStatus,
4647
} from "./presence.js";
4748

4849
export { acquirePresence } from "./experimentalAccess.js";

packages/framework/presence/src/presence.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ import type { ISubscribable } from "@fluid-experimental/presence/internal/events
3030
*/
3131
export type ClientSessionId = SessionId & { readonly ClientSessionId: "ClientSessionId" };
3232

33+
/**
34+
* The connection status of the {@link ISessionClient}.
35+
*
36+
* @alpha
37+
*/
38+
export const SessionClientStatus = {
39+
Connected: "Connected",
40+
Disconnected: "Disconnected",
41+
} as const;
42+
43+
/**
44+
* Type for the connection status of the {@link ISessionClient}.
45+
*
46+
* @alpha
47+
*/
48+
export type SessionClientStatus =
49+
(typeof SessionClientStatus)[keyof typeof SessionClientStatus];
50+
3351
/**
3452
* A client within a Fluid session (period of container connectivity to service).
3553
*
@@ -60,8 +78,18 @@ export interface ISessionClient<
6078
*
6179
* @remarks
6280
* Connection id will change on reconnect.
81+
*
82+
* If {@link ISessionClient.getStatus} is {@link (SessionClientStatus:variable).Disconnected}, this will represent the last known connection id.
83+
*/
84+
connectionId(): ClientConnectionId;
85+
86+
/**
87+
* Get status of session client.
88+
*
89+
* @returns Status of session client.
90+
*
6391
*/
64-
currentConnectionId(): ClientConnectionId;
92+
getStatus(): SessionClientStatus;
6593
}
6694

6795
/**

packages/framework/presence/src/presenceManager.ts

+20-2
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,24 @@ export type PresenceExtensionInterface = Required<
4444
Pick<IContainerExtension<never>, "processSignal">
4545
>;
4646

47+
/**
48+
* Internal managment of client connection ids.
49+
*/
50+
export interface ClientConnectionManager {
51+
/**
52+
* Remove the current client connection id from the corresponding disconnected attendee.
53+
*
54+
* @param clientConnectionId - The current client connection id to be removed.
55+
*/
56+
removeClientConnectionId(clientConnectionId: ClientConnectionId): void;
57+
}
58+
4759
/**
4860
* The Presence manager
4961
*/
50-
class PresenceManager implements IPresence, PresenceExtensionInterface {
62+
class PresenceManager
63+
implements IPresence, PresenceExtensionInterface, ClientConnectionManager
64+
{
5165
private readonly datastoreManager: PresenceDatastoreManager;
5266
private readonly systemWorkspace: SystemWorkspace;
5367

@@ -88,6 +102,10 @@ class PresenceManager implements IPresence, PresenceExtensionInterface {
88102
this.datastoreManager.joinSession(clientConnectionId);
89103
}
90104

105+
public removeClientConnectionId(clientConnectionId: ClientConnectionId): void {
106+
this.systemWorkspace.removeClientConnectionId(clientConnectionId);
107+
}
108+
91109
public getAttendees(): ReadonlySet<ISessionClient> {
92110
return this.systemWorkspace.getAttendees();
93111
}
@@ -169,6 +187,6 @@ function setupSubComponents(
169187
export function createPresenceManager(
170188
runtime: IEphemeralRuntime,
171189
clientSessionId: ClientSessionId = createSessionId() as ClientSessionId,
172-
): IPresence & PresenceExtensionInterface {
190+
): IPresence & PresenceExtensionInterface & ClientConnectionManager {
173191
return new PresenceManager(runtime, clientSessionId);
174192
}

packages/framework/presence/src/systemWorkspace.ts

+40-12
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { assert } from "@fluidframework/core-utils/internal";
77

88
import type { ClientConnectionId } from "./baseTypes.js";
99
import type { InternalTypes } from "./exposedInternalTypes.js";
10-
import type {
11-
ClientSessionId,
12-
IPresence,
13-
ISessionClient,
14-
PresenceEvents,
10+
import {
11+
SessionClientStatus,
12+
type ClientSessionId,
13+
type IPresence,
14+
type ISessionClient,
15+
type PresenceEvents,
1516
} from "./presence.js";
1617
import type { PresenceStatesInternal } from "./presenceStates.js";
1718
import type { PresenceStates, PresenceStatesSchema } from "./types.js";
@@ -32,7 +33,7 @@ export interface SystemWorkspaceDatastore {
3233
/**
3334
* There is no implementation class for this interface.
3435
* It is a simple structure. Most complicated aspect is that
35-
* `currentConnectionId()` member is replaced with a new
36+
* `connectionId()` member is replaced with a new
3637
* function when a more recent connection is added.
3738
*
3839
* See {@link SystemWorkspaceImpl.ensureAttendee}.
@@ -58,6 +59,13 @@ export interface SystemWorkspace
5859
* @param clientConnectionId - The new client connection id.
5960
*/
6061
onConnectionAdded(clientConnectionId: ClientConnectionId): void;
62+
63+
/**
64+
* Removes the client connection id from the system workspace.
65+
*
66+
* @param clientConnectionId - The client connection id to remove.
67+
*/
68+
removeClientConnectionId(clientConnectionId: ClientConnectionId): void;
6169
}
6270

6371
class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
@@ -74,14 +82,17 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
7482
public constructor(
7583
clientSessionId: ClientSessionId,
7684
private readonly datastore: SystemWorkspaceDatastore,
77-
public readonly events: IEmitter<Pick<PresenceEvents, "attendeeJoined">>,
85+
public readonly events: IEmitter<
86+
Pick<PresenceEvents, "attendeeJoined" | "attendeeDisconnected">
87+
>,
7888
) {
7989
this.selfAttendee = {
8090
sessionId: clientSessionId,
8191
order: 0,
82-
currentConnectionId: () => {
92+
connectionId: () => {
8393
throw new Error("Client has never been connected");
8494
},
95+
getStatus: () => SessionClientStatus.Disconnected,
8596
};
8697
this.attendees.set(clientSessionId, this.selfAttendee);
8798
}
@@ -139,10 +150,26 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
139150
value: this.selfAttendee.sessionId,
140151
};
141152

142-
this.selfAttendee.currentConnectionId = () => clientConnectionId;
153+
this.selfAttendee.connectionId = () => clientConnectionId;
154+
this.selfAttendee.getStatus = () => SessionClientStatus.Connected;
143155
this.attendees.set(clientConnectionId, this.selfAttendee);
144156
}
145157

158+
public removeClientConnectionId(clientConnectionId: ClientConnectionId): void {
159+
const attendee = this.attendees.get(clientConnectionId);
160+
if (!attendee) {
161+
return;
162+
}
163+
164+
// If the last known connectionID is different from the connection id being removed, the attendee has reconnected,
165+
// therefore we should not change the attendee connection status or emit a disconnect event.
166+
const attendeeReconnected = attendee.connectionId() !== clientConnectionId;
167+
if (!attendeeReconnected) {
168+
attendee.getStatus = () => SessionClientStatus.Disconnected;
169+
this.events.emit("attendeeDisconnected", attendee);
170+
}
171+
}
172+
146173
public getAttendees(): ReadonlySet<ISessionClient> {
147174
return new Set(this.attendees.values());
148175
}
@@ -174,7 +201,7 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
174201
clientConnectionId: ClientConnectionId,
175202
order: number,
176203
): { attendee: SessionClient; isNew: boolean } {
177-
const currentConnectionId = (): ClientConnectionId => clientConnectionId;
204+
const connectionId = (): ClientConnectionId => clientConnectionId;
178205
let attendee = this.attendees.get(clientSessionId);
179206
let isNew = false;
180207
if (attendee === undefined) {
@@ -183,15 +210,16 @@ class SystemWorkspaceImpl implements PresenceStatesInternal, SystemWorkspace {
183210
attendee = {
184211
sessionId: clientSessionId,
185212
order,
186-
currentConnectionId,
213+
connectionId,
214+
getStatus: () => SessionClientStatus.Connected,
187215
};
188216
this.attendees.set(clientSessionId, attendee);
189217
isNew = true;
190218
} else if (order > attendee.order) {
191219
// The given association is newer than the one we have.
192220
// Update the order and current connection id.
193221
attendee.order = order;
194-
attendee.currentConnectionId = currentConnectionId;
222+
attendee.connectionId = connectionId;
195223
}
196224
// Always update entry for the connection id. (Okay if already set.)
197225
this.attendees.set(clientConnectionId, attendee);

packages/framework/presence/src/test/presenceManager.spec.ts

+55-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { EventAndErrorTrackingLogger } from "@fluidframework/test-utils/internal
99
import type { SinonFakeTimers } from "sinon";
1010
import { useFakeTimers } from "sinon";
1111

12-
import type { ISessionClient } from "../presence.js";
12+
import { SessionClientStatus, type ISessionClient } from "../presence.js";
1313
import { createPresenceManager } from "../presenceManager.js";
1414

1515
import { MockEphemeralRuntime } from "./mockEphemeralRuntime.js";
@@ -125,12 +125,64 @@ describe("Presence", () => {
125125
"Attendee has wrong session id",
126126
);
127127
assert.equal(
128-
newAttendee.currentConnectionId(),
128+
newAttendee.connectionId(),
129129
initialAttendeeConnectionId,
130130
"Attendee has wrong client connection id",
131131
);
132132
});
133133

134+
describe("disconnects", () => {
135+
let disconnectedAttendee: ISessionClient | undefined;
136+
beforeEach(() => {
137+
disconnectedAttendee = undefined;
138+
afterCleanUp.push(
139+
presence.events.on("attendeeDisconnected", (attendee) => {
140+
assert(
141+
disconnectedAttendee === undefined,
142+
"Only one attendee should be disconnected",
143+
);
144+
disconnectedAttendee = attendee;
145+
}),
146+
);
147+
// Setup - simulate join message from client
148+
presence.processSignal("", initialAttendeeSignal, false);
149+
150+
// Act - remove client connection id
151+
presence.removeClientConnectionId(initialAttendeeConnectionId);
152+
});
153+
154+
it("is announced via `attendeeDisconnected` when audience member leaves", () => {
155+
// Verify
156+
assert(
157+
disconnectedAttendee !== undefined,
158+
"No attendee was disconnected in beforeEach",
159+
);
160+
assert.equal(
161+
disconnectedAttendee.sessionId,
162+
newAttendeeSessionId,
163+
"Disconnected attendee has wrong session id",
164+
);
165+
assert.equal(
166+
disconnectedAttendee.connectionId(),
167+
initialAttendeeConnectionId,
168+
"Disconnected attendee has wrong client connection id",
169+
);
170+
});
171+
172+
it("changes the session client status to `Disconnected`", () => {
173+
// Verify
174+
assert(
175+
disconnectedAttendee !== undefined,
176+
"No attendee was disconnected in beforeEach",
177+
);
178+
assert.equal(
179+
disconnectedAttendee.getStatus(),
180+
SessionClientStatus.Disconnected,
181+
"Disconnected attendee has wrong status",
182+
);
183+
});
184+
});
185+
134186
describe("already known", () => {
135187
beforeEach(() => {
136188
// Setup - simulate join message from client
@@ -199,7 +251,7 @@ describe("Presence", () => {
199251
);
200252
// Current connection id is updated
201253
assert(
202-
newAttendee.currentConnectionId() === updatedClientConnectionId,
254+
newAttendee.connectionId() === updatedClientConnectionId,
203255
"Attendee does not have updated client connection id",
204256
);
205257
// Attendee is available via new connection id

0 commit comments

Comments
 (0)