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.join → onRoomJoin. 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.
Summary
A
m.room.encryptionevent on a freshly-joined room produces two coupled log lines in our daemon's journal:The first is a benign warn from
CryptoClient.onRoomEvent's handled-rejection path. The second is an unhandled rejection escaping fromCryptoClient.onRoomJoin, which has the same failing call but no error handling. Both are triggered by the same root cause: a homeserver response wherechunkis null/missing, whichMatrixClient.getRoomMembersAtconsumes without guarding.The daemon stays up (we have a process-wide
unhandledRejectionhandler) 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:
Every instance follows the exact two-line pattern above.
Root cause
Three things stack:
1.
MatrixClient.getRoomMembersAtdoes not guardr['chunk'](src/MatrixClient.tslines ~1260–1269 in0.9.0-element.0, unchanged onmain):If the homeserver response has
chunk: null(or omitschunk), this throwsTypeError: Cannot read properties of null (reading 'map'), rejecting the promise.2.
CryptoClient.onRoomJoinhas no error handling around theawait(src/e2ee/CryptoClient.tslines ~144–150):Compare with the sibling
m.room.encryptionbranch inonRoomEvent(lines ~131–135), which does handle the rejection:The 2023 commit "Catch when CryptoClient can't find room members" added the guard to
onRoomEventbut notonRoomJoin.3.
MatrixClientwires both as fire-and-forget (src/MatrixClient.tslines ~154–161):So any rejection from
onRoomJoinescapes the SDK entirely.Why both lines appear
For a freshly-synced encrypted room, the sync stream delivers both
m.room.encryption(state event →onRoomEvent) androom.join→onRoomJoin. Both callgetRoomMembers(roomId, null, ['join', 'invite']). Both fail with the same TypeError fromgetRoomMembersAt. TheonRoomEventpath logs cleanly via the.then(_, errHandler); theonRoomJoinpath 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 existingonRoomEventpattern:(
RustEngine.prepareEncryptalready follows this pattern at lines ~138–146.)b. Guard
getRoomMembersAtso a malformed homeserver response throws a typed error instead of a genericTypeError: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-sdk0.9.0-element.0@matrix-org/matrix-sdk-crypto-nodejs0.5.1Severity
P2 / minor. Non-fatal: the daemon's global
unhandledRejectionhandler 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.