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 3 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
74 changes: 65 additions & 9 deletions spec/integ/matrix-client-methods.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,19 +1292,75 @@ describe("MatrixClient", function () {
});
});

describe("getCapabilities", () => {
it("should cache by default", async () => {
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: {
"m.change_password": false,
},
capabilities: capsObject,
});
const prom = httpBackend.flushAllExpected();
const capabilities1 = await client.getCapabilities();
const capabilities2 = await client.getCapabilities();

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,
});
});

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
100 changes: 23 additions & 77 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,23 @@ 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. If none have been fetched yet,
* return undefined.
*
* @returns 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;

this.cachedCapabilities = {
capabilities,
expiration: now + cacheMs,
};
public getCachedCapabilities(): Capabilities | undefined {
return this.serverCapabilitiesService.getCapabilities();
}

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
9 changes: 5 additions & 4 deletions src/models/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { MatrixClient, PendingEventOrdering } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
import { Filter, IFilterDefinition } from "../filter";
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
Expand Down Expand Up @@ -70,6 +70,7 @@ import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";
import * as utils from "../utils";
import { KnownMembership, Membership } from "../@types/membership";
import { IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities";

// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
Expand Down Expand Up @@ -611,8 +612,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Resolves to the version the room should be upgraded to.
*/
public async getRecommendedVersion(): Promise<IRecommendedVersion> {
const capabilities = await this.client.getCapabilities();
let versionCap = capabilities["m.room_versions"];
const capabilities = this.client.getCachedCapabilities();
Copy link
Member

Choose a reason for hiding this comment

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

What if getRecommendedVersion is called before the first capabilities response comes down?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point. I've put back a function that does the same as before.

let versionCap = (capabilities ?? {})["m.room_versions"];
if (!versionCap) {
versionCap = {
default: KNOWN_SAFE_ROOM_VERSION,
Expand All @@ -636,7 +637,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
"to be supporting a newer room version we don't know about.",
);

const caps = await this.client.getCapabilities(true);
const caps = await this.client.fetchCapabilities();
Copy link
Member

Choose a reason for hiding this comment

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

this would throw here now? instead of returning a default value? Would impact the react-sdk?

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, I forgot to catch the exception here actually.

versionCap = caps["m.room_versions"];
if (!versionCap) {
logger.warn("No room version capability - assuming upgrade required.");
Expand Down
6 changes: 3 additions & 3 deletions src/rendezvous/MSC3906Rendezvous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ import {
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from ".";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";
import { sleep } from "../utils";
import { CrossSigningKey } from "../crypto-api";
import { Device } from "../matrix";
import { Device, IGetLoginTokenCapability } from "../matrix";

enum PayloadType {
Start = "m.login.start",
Expand Down Expand Up @@ -109,7 +109,7 @@ export class MSC3906Rendezvous {
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);

// in stable and unstable r1 the availability is exposed as a capability
const capabilities = await this.client.getCapabilities();
const capabilities = this.client.getCachedCapabilities();
dbkr marked this conversation as resolved.
Show resolved Hide resolved
// in r0 of MSC3882 the availability is exposed as a feature flag
const features = await buildFeatureSupportMap(await this.client.getVersions());
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
Expand Down
Loading
Loading