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

Fetch capabilities in the background #4246

Merged
merged 9 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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
4 changes: 2 additions & 2 deletions spec/integ/crypto/megolm-backup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe

const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
jest.runAllTimers();
jest.advanceTimersByTime(10 * 60 * 1000);
await failurePromise;

// Fix the endpoint to do successful uploads
Expand Down Expand Up @@ -829,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});

// run the timers, which will make the backup loop redo the request
await jest.runAllTimersAsync();
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
await successPromise;
await allKeysUploadedPromise;
});
Expand Down
107 changes: 99 additions & 8 deletions spec/integ/matrix-client-methods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1293,18 +1293,109 @@ describe("MatrixClient", function () {
});

describe("getCapabilities", () => {
it("should cache by default", async () => {
it("should return cached capabilities if present", async () => {
const capsObject = {
"m.change_password": false,
};

httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: {
"m.change_password": false,
},
capabilities: capsObject,
});

client.startClient();
await httpBackend!.flushAllExpected();

expect(await client.getCapabilities()).toEqual(capsObject);
});

it("should fetch capabilities if cache not present", async () => {
const capsObject = {
"m.change_password": false,
};

httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});

const capsPromise = client.getCapabilities();
await httpBackend!.flushAllExpected();

expect(await capsPromise).toEqual(capsObject);
});
});

describe("getCachedCapabilities", () => {
it("should return cached capabilities or undefined", async () => {
const capsObject = {
"m.change_password": false,
};

httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});

expect(client.getCachedCapabilities()).toBeUndefined();

client.startClient();

await httpBackend!.flushAllExpected();

expect(client.getCachedCapabilities()).toEqual(capsObject);
});
});

describe("fetchCapabilities", () => {
const capsObject = {
"m.change_password": false,
};

beforeEach(() => {
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
const prom = httpBackend.flushAllExpected();
const capabilities1 = await client.getCapabilities();
const capabilities2 = await client.getCapabilities();
});

afterEach(() => {
jest.useRealTimers();
});

it("should always fetch capabilities and then cache", async () => {
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
const caps = await prom;

expect(caps).toEqual(capsObject);
});

it("should write-through the cache", async () => {
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });

client.startClient();
await httpBackend!.flushAllExpected();

expect(client.getCachedCapabilities()).toEqual(capsObject);

const newCapsObject = {
"m.change_password": true,
};

httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: newCapsObject,
});

const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
await prom;

expect(capabilities1).toStrictEqual(capabilities2);
expect(client.getCachedCapabilities()).toEqual(newCapsObject);
});
});

Expand Down
9 changes: 5 additions & 4 deletions spec/integ/matrix-client-syncing-errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,20 +105,21 @@ describe("MatrixClient syncing errors", () => {

await client!.startClient();
expect(await syncEvents[0].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[1].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
});

it("should stop sync keep alive when client is stopped.", async () => {
jest.useFakeTimers();
fetchMock.config.overwriteRoutes = false;
fetchMock
.get("end:capabilities", {})
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
Expand Down
2 changes: 1 addition & 1 deletion spec/test-utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}),
getCachedCapabilities: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
});
2 changes: 1 addition & 1 deletion spec/unit/rendezvous/rendezvous.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function makeMockClient(opts: {
},
};
},
getCapabilities() {
getCachedCapabilities() {
return opts.msc3882r0Only
? {}
: {
Expand Down
110 changes: 34 additions & 76 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,14 +226,14 @@ import { getRelationsThreadFilter } from "./thread-utils";
import { KnownMembership, Membership } from "./@types/membership";
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
import { ImageInfo } from "./@types/media";
import { Capabilities, ServerCapabilities } from "./serverCapabilities";

export type Store = IStore;

export type ResetTimelineCallback = (roomId: string) => boolean;

const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes

export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
Expand Down Expand Up @@ -518,26 +518,6 @@ export interface IStartClientOpts {

export interface IStoredClientOpts extends IStartClientOpts {}

export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}

export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}

export interface ICapability {
enabled: boolean;
}

export interface IChangePasswordCapability extends ICapability {}

export interface IThreadsCapability extends ICapability {}

export interface IGetLoginTokenCapability extends ICapability {}

export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
"m.get_login_token",
"org.matrix.msc3882.get_login_token",
Expand All @@ -547,19 +527,6 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";

/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
}

enum CrossSigningKeyType {
MasterKey = "master_key",
SelfSigningKey = "self_signing_key",
Expand Down Expand Up @@ -1293,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
protected serverVersionsPromise?: Promise<IServerVersions>;

public cachedCapabilities?: {
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown;
protected clientWellKnownPromise?: Promise<IClientWellKnown>;
protected turnServers: ITurnServer[] = [];
Expand Down Expand Up @@ -1325,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa

public readonly matrixRTC: MatrixRTCSessionManager;

private serverCapabilitiesService: ServerCapabilities;

public constructor(opts: IMatrixClientCreateOpts) {
super();

Expand Down Expand Up @@ -1418,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this);

this.serverCapabilitiesService = new ServerCapabilities(this.http);

this.on(ClientEvent.Sync, this.fixupRoomNotifications);

this.timelineSupport = Boolean(opts.timelineSupport);
Expand Down Expand Up @@ -1540,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}

this.toDeviceMessageQueue.start();
this.serverCapabilitiesService.start();
}

/**
Expand Down Expand Up @@ -1593,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.stop();

this.matrixRTC.stop();

this.serverCapabilitiesService.stop();
}

/**
Expand Down Expand Up @@ -2095,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}

/**
* Gets the capabilities of the homeserver. Always returns an object of
* capability keys and their options, which may be empty.
* @param fresh - True to ignore any cached values.
* @returns Promise which resolves to the capabilities of the homeserver
* @returns Rejects: with an error response.
* Gets the cached capabilities of the homeserver, returning cached ones if available.
* If there are no cached capabilities and none can be fetched, throw an exception.
*
* @returns Promise resolving with The capabilities of the homeserver
*/
public getCapabilities(fresh = false): Promise<Capabilities> {
const now = new Date().getTime();

if (this.cachedCapabilities && !fresh) {
if (now < this.cachedCapabilities.expiration) {
this.logger.debug("Returning cached capabilities");
return Promise.resolve(this.cachedCapabilities.capabilities);
}
}

type Response = {
capabilities?: Capabilities;
};
return this.http
.authedRequest<Response>(Method.Get, "/capabilities")
.catch((e: Error): Response => {
// We swallow errors because we need a default object anyhow
this.logger.error(e);
return {};
})
.then((r = {}) => {
const capabilities = r["capabilities"] || {};

// If the capabilities missed the cache, cache it for a shorter amount
// of time to try and refresh them later.
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
public async getCapabilities(): Promise<Capabilities> {
const caps = this.serverCapabilitiesService.getCachedCapabilities();
if (caps) return caps;
return this.serverCapabilitiesService.fetchCapabilities();
Copy link
Member

Choose a reason for hiding this comment

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

So it doesn't swallow errors anymore? only rendez-vous was calling it?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, swallowing errors silently and returning something seems like an awful behaviour, so I've changed it favour of having the places its used catch the exception instead, and if they choose to ignore it then that's up to them. It was used in a couple of places, not that many.

}

this.cachedCapabilities = {
capabilities,
expiration: now + cacheMs,
};
/**
* Gets the cached capabilities of the homeserver. If none have been fetched yet,
* return undefined.
*
* @returns The capabilities of the homeserver
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.serverCapabilitiesService.getCachedCapabilities();
}

this.logger.debug("Caching capabilities: ", capabilities);
return capabilities;
});
/**
* Fetches the latest capabilities from the homeserver, ignoring any cached
* versions. The newly returned version is cached.
*
* @returns A promise which resolves to the capabilities of the homeserver
*/
public fetchCapabilities(): Promise<Capabilities> {
return this.serverCapabilitiesService.fetchCapabilities();
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
import { CryptoStore } from "./crypto/store/base";

export * from "./client";
export * from "./serverCapabilities";
export * from "./embedded";
export * from "./http-api";
export * from "./autodiscovery";
Expand Down
Loading
Loading