Skip to content

Commit 84d0a79

Browse files
committed
fix: harden matrix startup errors + add regressions (#31023) (thanks @efe-arv)
1 parent 235ed71 commit 84d0a79

File tree

7 files changed

+212
-5
lines changed

7 files changed

+212
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai
9494

9595
- Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin.
9696
- Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek.
97+
- Matrix/Conduit compatibility: avoid blocking startup on non-resolving Matrix sync start, preserve startup error propagation, prevent duplicate monitor listener registration, remove unreliable 2-member DM heuristics, accept `!room` IDs without alias resolution, and add matrix monitor/client regression coverage. Landed from contributor PR #31023 by @efe-arv. Thanks @efe-arv.
9798
- Security/Skills: harden skill installer metadata parsing by rejecting unsafe installer specs (brew/node/go/uv/download) and constrain plugin-declared skill directories to the plugin root (including symlink-escape checks), with regression coverage.
9899
- Discord/DM command auth: unify DM allowlist + pairing-store authorization across message preflight and native command interactions so DM command gating is consistent for `open`/`pairing`/`allowlist` policies.
99100
- Sessions/Usage accounting: persist `cacheRead`/`cacheWrite` from the latest call snapshot (`lastCallUsage`) instead of accumulated multi-call totals, preventing inflated token/cost reporting in long tool/compaction runs. (#31005)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { resolveSharedMatrixClient, stopSharedClient } from "./shared.js";
4+
import type { MatrixAuth } from "./types.js";
5+
6+
const createMatrixClientMock = vi.hoisted(() => vi.fn());
7+
8+
vi.mock("./create-client.js", () => ({
9+
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
10+
}));
11+
12+
function makeAuth(suffix: string): MatrixAuth {
13+
return {
14+
homeserver: "https://matrix.example.org",
15+
userId: `@bot-${suffix}:example.org`,
16+
accessToken: `token-${suffix}`,
17+
encryption: false,
18+
};
19+
}
20+
21+
function createMockClient(startImpl: () => Promise<void>): MatrixClient {
22+
return {
23+
start: vi.fn(startImpl),
24+
stop: vi.fn(),
25+
getJoinedRooms: vi.fn().mockResolvedValue([]),
26+
crypto: undefined,
27+
} as unknown as MatrixClient;
28+
}
29+
30+
describe("resolveSharedMatrixClient startup behavior", () => {
31+
afterEach(() => {
32+
stopSharedClient();
33+
createMatrixClientMock.mockReset();
34+
vi.useRealTimers();
35+
});
36+
37+
it("propagates the original start error during initialization", async () => {
38+
vi.useFakeTimers();
39+
const startError = new Error("bad token");
40+
const client = createMockClient(
41+
() =>
42+
new Promise<void>((_resolve, reject) => {
43+
setTimeout(() => reject(startError), 1);
44+
}),
45+
);
46+
createMatrixClientMock.mockResolvedValue(client);
47+
48+
const startPromise = resolveSharedMatrixClient({
49+
auth: makeAuth("start-error"),
50+
});
51+
const startExpectation = expect(startPromise).rejects.toBe(startError);
52+
53+
await vi.advanceTimersByTimeAsync(2001);
54+
await startExpectation;
55+
});
56+
57+
it("retries start after a late start-loop failure", async () => {
58+
vi.useFakeTimers();
59+
let rejectFirstStart: ((err: unknown) => void) | undefined;
60+
const firstStart = new Promise<void>((_resolve, reject) => {
61+
rejectFirstStart = reject;
62+
});
63+
const secondStart = new Promise<void>(() => {});
64+
const startMock = vi.fn().mockReturnValueOnce(firstStart).mockReturnValueOnce(secondStart);
65+
const client = createMockClient(startMock);
66+
createMatrixClientMock.mockResolvedValue(client);
67+
68+
const firstResolve = resolveSharedMatrixClient({
69+
auth: makeAuth("late-failure"),
70+
});
71+
await vi.advanceTimersByTimeAsync(2000);
72+
await expect(firstResolve).resolves.toBe(client);
73+
expect(startMock).toHaveBeenCalledTimes(1);
74+
75+
rejectFirstStart?.(new Error("late failure"));
76+
await Promise.resolve();
77+
78+
const secondResolve = resolveSharedMatrixClient({
79+
auth: makeAuth("late-failure"),
80+
});
81+
await vi.advanceTimersByTimeAsync(2000);
82+
await expect(secondResolve).resolves.toBe(client);
83+
expect(startMock).toHaveBeenCalledTimes(2);
84+
});
85+
});

extensions/matrix/src/matrix/client/shared.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,18 +92,19 @@ async function ensureSharedClientStarted(params: {
9292
// resolveSharedMatrixClient() calls know to retry.
9393
const startPromiseInner = client.start();
9494
let settled = false;
95+
let startError: unknown = undefined;
9596
startPromiseInner.catch((err: unknown) => {
9697
settled = true;
98+
startError = err;
9799
params.state.started = false;
98100
LogService.error("MatrixClientLite", "client.start() error:", err);
99101
});
100102
// Give the sync loop a moment to initialize before marking ready
101-
await new Promise(resolve => setTimeout(resolve, 2000));
103+
await new Promise((resolve) => setTimeout(resolve, 2000));
102104
if (settled) {
103-
throw new Error("Matrix client.start() failed during initialization");
105+
throw startError;
104106
}
105107
params.state.started = true;
106-
107108
})();
108109
sharedClientStartPromises.set(key, startPromise);
109110
try {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { createDirectRoomTracker } from "./direct.js";
4+
5+
function createMockClient(params: {
6+
isDm?: boolean;
7+
senderDirect?: boolean;
8+
selfDirect?: boolean;
9+
members?: string[];
10+
}) {
11+
const members = params.members ?? ["@alice:example.org", "@bot:example.org"];
12+
return {
13+
dms: {
14+
update: vi.fn().mockResolvedValue(undefined),
15+
isDm: vi.fn().mockReturnValue(params.isDm === true),
16+
},
17+
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
18+
getJoinedRoomMembers: vi.fn().mockResolvedValue(members),
19+
getRoomStateEvent: vi
20+
.fn()
21+
.mockImplementation(async (_roomId: string, _event: string, stateKey: string) => {
22+
if (stateKey === "@alice:example.org") {
23+
return { is_direct: params.senderDirect === true };
24+
}
25+
if (stateKey === "@bot:example.org") {
26+
return { is_direct: params.selfDirect === true };
27+
}
28+
return {};
29+
}),
30+
} as unknown as MatrixClient;
31+
}
32+
33+
describe("createDirectRoomTracker", () => {
34+
it("treats m.direct rooms as DMs", async () => {
35+
const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
36+
await expect(
37+
tracker.isDirectMessage({
38+
roomId: "!room:example.org",
39+
senderId: "@alice:example.org",
40+
}),
41+
).resolves.toBe(true);
42+
});
43+
44+
it("does not classify 2-member rooms as DMs without direct flags", async () => {
45+
const tracker = createDirectRoomTracker(createMockClient({ isDm: false }));
46+
await expect(
47+
tracker.isDirectMessage({
48+
roomId: "!room:example.org",
49+
senderId: "@alice:example.org",
50+
}),
51+
).resolves.toBe(false);
52+
});
53+
54+
it("uses is_direct member flags when present", async () => {
55+
const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true }));
56+
await expect(
57+
tracker.isDirectMessage({
58+
roomId: "!room:example.org",
59+
senderId: "@alice:example.org",
60+
}),
61+
).resolves.toBe(true);
62+
});
63+
});

extensions/matrix/src/matrix/monitor/events.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,38 @@ describe("registerMatrixMonitorEvents", () => {
138138
expect(getUserId).toHaveBeenCalledTimes(1);
139139
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
140140
});
141+
142+
it("skips duplicate listener registration for the same client", () => {
143+
const handlers = new Map<string, (...args: unknown[]) => void>();
144+
const onMock = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
145+
handlers.set(event, handler);
146+
});
147+
const client = {
148+
on: onMock,
149+
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
150+
crypto: undefined,
151+
} as unknown as MatrixClient;
152+
const params = {
153+
client,
154+
auth: { encryption: false } as MatrixAuth,
155+
logVerboseMessage: vi.fn(),
156+
warnedEncryptedRooms: new Set<string>(),
157+
warnedCryptoMissingRooms: new Set<string>(),
158+
logger: { warn: vi.fn() } as unknown as RuntimeLogger,
159+
formatNativeDependencyHint: (() =>
160+
"") as PluginRuntime["system"]["formatNativeDependencyHint"],
161+
onRoomMessage: vi.fn(),
162+
};
163+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
164+
165+
registerMatrixMonitorEvents(params);
166+
const initialCallCount = onMock.mock.calls.length;
167+
registerMatrixMonitorEvents(params);
168+
169+
expect(onMock).toHaveBeenCalledTimes(initialCallCount);
170+
expect(errorSpy).toHaveBeenCalledWith(
171+
"[matrix] skipping duplicate listener registration for client",
172+
);
173+
errorSpy.mockRestore();
174+
});
141175
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from "vitest";
2+
import { DEFAULT_STARTUP_GRACE_MS, isConfiguredMatrixRoomEntry } from "./index.js";
3+
4+
describe("monitorMatrixProvider helpers", () => {
5+
it("treats !-prefixed room IDs as configured room entries", () => {
6+
expect(isConfiguredMatrixRoomEntry("!abc123")).toBe(true);
7+
expect(isConfiguredMatrixRoomEntry("!RoomMixedCase")).toBe(true);
8+
});
9+
10+
it("requires a homeserver suffix for # aliases", () => {
11+
expect(isConfiguredMatrixRoomEntry("#alias:example.org")).toBe(true);
12+
expect(isConfiguredMatrixRoomEntry("#alias")).toBe(false);
13+
});
14+
15+
it("uses a non-zero startup grace window", () => {
16+
expect(DEFAULT_STARTUP_GRACE_MS).toBe(5000);
17+
});
18+
});

extensions/matrix/src/matrix/monitor/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export type MonitorMatrixOpts = {
3636
};
3737

3838
const DEFAULT_MEDIA_MAX_MB = 20;
39+
export const DEFAULT_STARTUP_GRACE_MS = 5000;
40+
41+
export function isConfiguredMatrixRoomEntry(entry: string): boolean {
42+
return entry.startsWith("!") || (entry.startsWith("#") && entry.includes(":"));
43+
}
3944

4045
export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
4146
if (isBunRuntime()) {
@@ -153,7 +158,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
153158
continue;
154159
}
155160
const cleaned = normalizeRoomEntry(trimmed);
156-
if (cleaned.startsWith("!") || (cleaned.startsWith("#") && cleaned.includes(":"))) {
161+
if (isConfiguredMatrixRoomEntry(cleaned)) {
157162
if (!nextRooms[cleaned]) {
158163
nextRooms[cleaned] = roomConfig;
159164
}
@@ -268,7 +273,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
268273
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
269274
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
270275
const startupMs = Date.now();
271-
const startupGraceMs = 5000; // 5s grace for slow homeservers (e.g. Conduit filter M_NOT_FOUND retry)
276+
const startupGraceMs = DEFAULT_STARTUP_GRACE_MS;
272277
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
273278
registerMatrixAutoJoin({ client, cfg, runtime });
274279
const warnedEncryptedRooms = new Set<string>();

0 commit comments

Comments
 (0)