Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test(client-presence): old collateral connection and stale connection tests #23351

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/framework/presence/src/internalTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
*/

import type { IContainerRuntime } from "@fluidframework/container-runtime-definitions/internal";
import type { IEvent, IEventProvider } from "@fluidframework/core-interfaces";
import type { IFluidDataStoreRuntime } from "@fluidframework/datastore-definitions/internal";

import type { ClientConnectionId } from "./baseTypes.js";
import type { InternalTypes } from "./exposedInternalTypes.js";
import type { ClientSessionId, ISessionClient } from "./presence.js";

Expand Down Expand Up @@ -41,9 +43,19 @@ export const brandedObjectEntries = Object.entries as <K extends string, T>(
*/
export type IEphemeralRuntime = Pick<
(IContainerRuntime & IRuntimeInternal) | IFluidDataStoreRuntime,
"clientId" | "connected" | "getAudience" | "getQuorum" | "off" | "on" | "submitSignal"
"clientId" | "connected" | "getAudience" | "getQuorum" | "submitSignal"
> &
Partial<Pick<IFluidDataStoreRuntime, "logger">>;
Partial<Pick<IFluidDataStoreRuntime, "logger">> &
IEventProvider<EphemeralRuntimeEvents>;

/**
* Events emitted by {@link IEphemeralRuntime}.
* @internal
*/
export interface EphemeralRuntimeEvents extends IEvent {
(event: "connected", listener: (clientId: ClientConnectionId) => void): void;
(event: "disconnected", listener: () => void): void;
}

/**
* @internal
Expand Down
62 changes: 18 additions & 44 deletions packages/framework/presence/src/test/mockEphemeralRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@

import { strict as assert } from "node:assert";

import { TypedEventEmitter } from "@fluid-internal/client-utils";
import type { ITelemetryBaseLogger } from "@fluidframework/core-interfaces";
import type { IClient, ISequencedClient } from "@fluidframework/driver-definitions";
import { MockAudience, MockQuorumClients } from "@fluidframework/test-runtime-utils/internal";

import type { ClientConnectionId } from "../baseTypes.js";
import type { IEphemeralRuntime } from "../internalTypes.js";
import type { EphemeralRuntimeEvents, IEphemeralRuntime } from "../internalTypes.js";

type ClientData = [string, IClient];

Expand Down Expand Up @@ -66,26 +67,19 @@ function makeMockAudience(clients: ClientData[]): MockAudience {
/**
* Mock ephemeral runtime for testing
*/
export class MockEphemeralRuntime implements IEphemeralRuntime {
export class MockEphemeralRuntime
extends TypedEventEmitter<EphemeralRuntimeEvents>
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
implements IEphemeralRuntime
{
public logger?: ITelemetryBaseLogger;
public readonly quorum: MockQuorumClients;
public readonly audience: MockAudience;

public readonly listeners: {
connected: ((clientId: ClientConnectionId) => void)[];
disconnected: (() => void)[];
} = {
connected: [],
disconnected: [],
};
private isSupportedEvent(event: string): event is keyof typeof this.listeners {
return event in this.listeners;
}

public constructor(
logger?: ITelemetryBaseLogger,
public readonly signalsExpected: Parameters<IEphemeralRuntime["submitSignal"]>[] = [],
) {
super();
if (logger !== undefined) {
this.logger = logger;
}
Expand All @@ -98,29 +92,6 @@ export class MockEphemeralRuntime implements IEphemeralRuntime {
this.getQuorum = () => this.quorum;
this.audience = makeMockAudience(clientsData);
this.getAudience = () => this.audience;
this.on = (
event: string,
listener: (...args: any[]) => void,
// Events style eventing does not lend itself to union that
// IEphemeralRuntime is derived from, so we are using `any` here
// but meet the intent of the interface.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => {
if (!this.isSupportedEvent(event)) {
throw new Error(`Event ${event} is not supported`);
}
// Switch to allowing a single listener as commented when
// implementation uses a single "connected" listener.
// if (this.listeners[event]) {
// throw new Error(`Event ${event} already has a listener`);
// }
// this.listeners[event] = listener;
if (this.listeners[event].length > 1) {
throw new Error(`Event ${event} already has multiple listeners`);
}
this.listeners[event].push(listener);
return this;
};
}

public assertAllSignalsSubmitted(): void {
Expand All @@ -145,19 +116,22 @@ export class MockEphemeralRuntime implements IEphemeralRuntime {
this.audience.removeMember(clientId);
}

public connect(clientId: string): void {
this.clientId = clientId;
this.emit("connected", this.clientId);
this.connected = true;
}

public disconnect(): void {
this.emit("disconnected");
this.connected = false;
}

// #region IEphemeralRuntime

public clientId: string | undefined;
public connected: boolean = false;

public on: IEphemeralRuntime["on"];

public off: IEphemeralRuntime["off"] = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => {
throw new Error("IEphemeralRuntime.off method not implemented.");
};

public getAudience: () => ReturnType<IEphemeralRuntime["getAudience"]>;

public getQuorum: () => ReturnType<IEphemeralRuntime["getQuorum"]>;
Expand Down
140 changes: 139 additions & 1 deletion packages/framework/presence/src/test/presenceManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ describe("Presence", () => {
verifyAttendee(joinedAttendees[0], rejoinAttendeeConnectionId, attendeeSessionId);
});

it("as collateral and disconnected is NOT announced via `attendeeJoined`", () => {
it.skip("as collateral and disconnected is NOT announced via `attendeeJoined`", () => {
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
// Setup - remove connections from audience
const collateralAttendeeConnectionId = "client3";
const collateralAttendeeSignal = generateBasicClientJoin(clock.now - 10, {
Expand Down Expand Up @@ -282,6 +282,70 @@ describe("Presence", () => {

verifyAttendee(joinedAttendees[0], rejoinAttendeeConnectionId, attendeeSessionId);
});
it.skip("as collateral with old connection info and connected is NOT announced via `attendeeJoined`", () => {
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
// Setup - generate signals
const oldAttendeeConnectionId = "client9";
const newAttendeeConnectionId = "client10";
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved

// Rejoin signal for attendee
const rejoinSignal = generateBasicClientJoin(clock.now - 10, {
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
averageLatency: 40,
clientSessionId: "collateral-id",
clientConnectionId: newAttendeeConnectionId,
updateProviders: [initialAttendeeConnectionId],
connectionOrder: 1,
priorClientToSessionId: {
[oldAttendeeConnectionId]: {
rev: 0,
timestamp: 0,
value: "collateral-id",
},
},
});

const responseSignal = generateBasicClientJoin(clock.now - 5, {
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
averageLatency: 20,
clientSessionId: attendeeSessionId,
clientConnectionId: initialAttendeeConnectionId,
priorClientToSessionId: {
...initialAttendeeSignal.content.data["system:presence"].clientToSessionId,
// Old connection id of rejoining attendee
// This should be ignored by local client
[oldAttendeeConnectionId]: {
rev: 0,
timestamp: 0,
value: "collateral-id",
},
},
});

// Process initial join signal so initial attendee is known
const joinedAttendees = processJoinSignals([initialAttendeeSignal]);
assert.strictEqual(
joinedAttendees.length,
1,
"Expected exactly one attendee to be announced",
);

// Act & Verify - simulate rejoin message from remote client
const rejoinAttendees = processJoinSignals([rejoinSignal]);
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
assert.strictEqual(
rejoinAttendees.length,
1,
"Expected exactly one attendee to be announced",
);
verifyAttendee(rejoinAttendees[0], newAttendeeConnectionId, "collateral-id");

// Act & Verify - simulate response message from remote client
const responseAttendees = processJoinSignals([responseSignal]);
assert.strictEqual(
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
responseAttendees.length,
0,
"Expected no attendees to be announced",
);
// Verify attendee information remains unchanged
verifyAttendee(rejoinAttendees[0], newAttendeeConnectionId, "collateral-id");
});
});

describe("that is already known", () => {
Expand Down Expand Up @@ -429,6 +493,80 @@ describe("Presence", () => {
clientToDisconnect,
);
});
it.skip("updates stale attendees status to 'Disconnected", () => {
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
// Setup
assert.ok(knownAttendee, "No attendee was set in beforeEach");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is assert.ok? Can this be something more specific? Checking for defined, right?

Copy link
Contributor Author

@WillieHabi WillieHabi Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to assert.notEqual

assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Connected,
"Known attendee is not connected",
);

// Act - disconnect & reconnect local client
runtime.disconnect(); // Simulate local client disconnect
clock.tick(1000);
runtime.connect(rejoinAttendeeConnectionId); // Sinulate local client reconnect with new connection id
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved

// Verify - stale attendee should still be connected after 15 seconds
clock.tick(15001);
assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Connected,
"Stale attendee should still be connected",
);

// Verify - stale attendee should be disconnected after 30 seconds
clock.tick(15001);
assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Disconnected,
"Stale attendee has wrong status",
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And it is announced, right? Please verify.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made sure to check for attendeeDisconnected, made some changes in setup so announced disconnectedAttendees can be verified similar to joinedAttendees

});

it.skip("updates stale attendees status to 'Disconnected' afer multiple reconnects", () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

between these cases I am curious what happens after local connect is disconnected again. Do the stale connections ever become disconnected?
And what about the case where local disconnects, connects, and we hear from one of the stale connections?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a few other test cases to the existing ones:

  • stale attendee status is not updated to 'Disconnected' if local client does not reconnect
  • stale attendee status is not updated to 'Disconnected' if local client reconnection lasts less than 30s
  • attendee status is not updated to 'Disconnected' if stale attendee becomes active

// Setup
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved
assert.ok(knownAttendee, "No attendee was set in beforeEach");
assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Connected,
"Known attendee is not connected",
);

// Act - disconnect & reconnect local client
runtime.disconnect(); // Simulate local client disconnect
clock.tick(1000);
runtime.connect(rejoinAttendeeConnectionId); // Sinulate local client reconnect with new connection id
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved

// Verify - stale attendee should still be connected after 15 seconds
clock.tick(15001);
assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Connected,
"Stale attendee should still be connected",
);

// Act - disconnect & reconnect local client
runtime.disconnect(); // Simulate local client disconnect
clock.tick(1000);
runtime.connect("client7"); // Sinulate local client reconnect with new connection id

// Verify - stale attendee should still be connected after 15 seconds
clock.tick(15001);
assert.strictEqual(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Connected,
"Stale attendee should still be connected",
);
WillieHabi marked this conversation as resolved.
Show resolved Hide resolved

// Verify - stale attendee
clock.tick(15001);
assert.equal(
knownAttendee.getConnectionStatus(),
SessionClientStatus.Disconnected,
"Stale attendee has wrong status",
);
});
});
});

Expand Down
Loading