Skip to content

Unhandled promise rejection in CryptoClient.onRoomJoin when getRoomMembers fails #97

Description

@waspeer

Summary

A m.room.encryption event on a freshly-joined room produces two coupled log lines in our daemon's journal:

CryptoClient Unable to get members of room !XXXXXXXXX:beeper.local
ERROR unhandled_rejection error="Cannot read properties of null (reading 'map')"

The first is a benign warn from CryptoClient.onRoomEvent's handled-rejection path. The second is an unhandled rejection escaping from CryptoClient.onRoomJoin, which has the same failing call but no error handling. Both are triggered by the same root cause: a homeserver response where chunk is null/missing, which MatrixClient.getRoomMembersAt consumes without guarding.

The daemon stays up (we have a process-wide unhandledRejection handler) but whatever crypto-side work was in progress for that room is silently dropped.

Reproduction signal

Across 14 days on a long-lived sync client, 5 occurrences, in two non-crash-loop process lifetimes:

2026-05-14 10:21:55  PID 828
2026-05-14 12:03:26  PID 828
2026-05-14 12:24:59  PID 828
2026-05-24 09:28:48  PID 828
2026-05-24 16:46:27  PID 3251594  room=!WjLLplWxuaY82uQlwZxc:beeper.local

Every instance follows the exact two-line pattern above.

Root cause

Three things stack:

1. MatrixClient.getRoomMembersAt does not guard r['chunk'] (src/MatrixClient.ts lines ~1260–1269 in 0.9.0-element.0, unchanged on main):

private getRoomMembersAt(roomId, membership, notMembership, atToken): Promise<MembershipEvent[]> {
    const qs = {};
    if (atToken) qs["at"] = atToken;
    if (membership) qs["membership"] = membership;
    if (notMembership) qs["not_membership"] = notMembership;

    return this.doRequest("GET", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/members", qs).then(r => {
        return r['chunk'].map(e => new MembershipEvent(e));   // <-- throws if chunk is null
    });
}

If the homeserver response has chunk: null (or omits chunk), this throws TypeError: Cannot read properties of null (reading 'map'), rejecting the promise.

2. CryptoClient.onRoomJoin has no error handling around the await (src/e2ee/CryptoClient.ts lines ~144–150):

public async onRoomJoin(roomId: string) {
    await this.roomTracker.onRoomJoin(roomId);
    if (await this.isRoomEncrypted(roomId)) {
        const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']);
        await this.engine.addTrackedUsers(members.map(e => e.membershipFor));
    }
}

Compare with the sibling m.room.encryption branch in onRoomEvent (lines ~131–135), which does handle the rejection:

return this.client.getRoomMembers(roomId, null, ['join', 'invite']).then(
    members => this.engine.addTrackedUsers(members.map(e => e.membershipFor)),
    e => void LogService.warn("CryptoClient", `Unable to get members of room ${roomId}`),
);

The 2023 commit "Catch when CryptoClient can't find room members" added the guard to onRoomEvent but not onRoomJoin.

3. MatrixClient wires both as fire-and-forget (src/MatrixClient.ts lines ~154–161):

this.on("room.event", (roomId, event) => {
    // noinspection JSIgnoredPromiseFromCall
    this.crypto.onRoomEvent(roomId, event);
});
this.on("room.join", (roomId) => {
    // noinspection JSIgnoredPromiseFromCall
    this.crypto.onRoomJoin(roomId);
});

So any rejection from onRoomJoin escapes the SDK entirely.

Why both lines appear

For a freshly-synced encrypted room, the sync stream delivers both m.room.encryption (state event → onRoomEvent) and room.joinonRoomJoin. Both call getRoomMembers(roomId, null, ['join', 'invite']). Both fail with the same TypeError from getRoomMembersAt. The onRoomEvent path logs cleanly via the .then(_, errHandler); the onRoomJoin path goes unhandled.

Suggested fix

Two complementary changes (either alone closes the unhandled-rejection signal; together they're defence-in-depth):

a. Catch in onRoomJoin, matching the existing onRoomEvent pattern:

public async onRoomJoin(roomId: string) {
    await this.roomTracker.onRoomJoin(roomId);
    if (await this.isRoomEncrypted(roomId)) {
        try {
            const members = await this.client.getRoomMembers(roomId, null, ['join', 'invite']);
            await this.engine.addTrackedUsers(members.map(e => e.membershipFor));
        } catch (e) {
            LogService.warn("CryptoClient", `Unable to get members of room ${roomId}`, extractRequestError(e));
        }
    }
}

(RustEngine.prepareEncrypt already follows this pattern at lines ~138–146.)

b. Guard getRoomMembersAt so a malformed homeserver response throws a typed error instead of a generic TypeError:

return this.doRequest(...).then(r => {
    if (!Array.isArray(r?.chunk)) {
        throw new Error(`Malformed /members response for ${roomId}: missing or non-array chunk`);
    }
    return r.chunk.map(e => new MembershipEvent(e));
});

Returning [] instead of throwing would also work but would hide homeserver bugs from callers that legitimately expect a populated list (e.g. prepareEncrypt).

Environment

  • @vector-im/matrix-bot-sdk 0.9.0-element.0
  • @matrix-org/matrix-sdk-crypto-nodejs 0.5.1
  • Node 24.16.0
  • Linux (Ubuntu, ARM64) — platform-independent
  • Homeserver: Beeper (hungryserv)

Severity

P2 / minor. Non-fatal: the daemon's global unhandledRejection handler logs and continues. The crypto-side work for the affected room is silently abandoned for that tick, which can manifest as occasional UTDs that are otherwise hard to attribute. For low-traffic deployments it's nuisance-level; for high-traffic ones it could be a notable contributor to unattributable UTDs.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions