Skip to content

Commit f2a17b2

Browse files
authored
Fix Google Meet chrome-node bridge cleanup (#72372)
Fixes #72371. Remote proof: - CI run 24980121791 passed on d583a6b. - Blacksmith Testbox tbx_01kq6t5jk2f51gxq30j9veyjhy passed focused Google Meet formatting and tests. Thanks @BsnizND. Co-authored-by: BSnizND <[email protected]>
1 parent 5c591a4 commit f2a17b2

7 files changed

Lines changed: 424 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
2222
- Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc.
2323
- Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant.
24+
- Google Meet: clean stale chrome-node realtime audio bridges by URL before rejoining, expose active node bridge inspection, and tolerate transient node input pull failures instead of dropping the Meet session. Fixes #72371. (#72372) Thanks @BsnizND.
2425
- Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras.
2526
- Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors.
2627
- Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech.

extensions/google-meet/index.test.ts

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
fetchGoogleMeetSpace,
2222
normalizeGoogleMeetSpaceName,
2323
} from "./src/meet.js";
24+
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
2425
import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
2526
import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
2627
import { normalizeMeetUrl } from "./src/runtime.js";
@@ -1326,6 +1327,17 @@ describe("google-meet plugin", () => {
13261327

13271328
expect(respond.mock.calls[0]?.[0]).toBe(true);
13281329
expect(nodesList.mock.calls[0]).toEqual([]);
1330+
expect(nodesInvoke).toHaveBeenCalledWith(
1331+
expect.objectContaining({
1332+
nodeId: "node-1",
1333+
command: "googlemeet.chrome",
1334+
params: expect.objectContaining({
1335+
action: "stopByUrl",
1336+
url: "https://meet.google.com/abc-defg-hij",
1337+
mode: "transcribe",
1338+
}),
1339+
}),
1340+
);
13291341
expect(nodesInvoke).toHaveBeenCalledWith(
13301342
expect.objectContaining({
13311343
nodeId: "node-1",
@@ -1394,7 +1406,7 @@ describe("google-meet plugin", () => {
13941406

13951407
expect(
13961408
nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
1397-
).toHaveLength(1);
1409+
).toHaveLength(2);
13981410
expect(second.mock.calls[0]?.[1]).toMatchObject({
13991411
session: {
14001412
chrome: { health: { inCall: true, micMuted: false } },
@@ -1438,7 +1450,7 @@ describe("google-meet plugin", () => {
14381450

14391451
expect(
14401452
nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
1441-
).toHaveLength(1);
1453+
).toHaveLength(2);
14421454
expect(second.mock.calls[0]?.[1]).toMatchObject({
14431455
session: {
14441456
notes: expect.arrayContaining(["Reused existing active Meet session."]),
@@ -2168,4 +2180,147 @@ describe("google-meet plugin", () => {
21682180
}),
21692181
);
21702182
});
2183+
2184+
it("keeps paired-node realtime audio alive after transient input pull failures", async () => {
2185+
const sendAudio = vi.fn();
2186+
const bridge = {
2187+
connect: vi.fn(async () => {}),
2188+
sendAudio,
2189+
setMediaTimestamp: vi.fn(),
2190+
submitToolResult: vi.fn(),
2191+
acknowledgeMark: vi.fn(),
2192+
close: vi.fn(),
2193+
triggerGreeting: vi.fn(),
2194+
isConnected: vi.fn(() => true),
2195+
};
2196+
const provider: RealtimeVoiceProviderPlugin = {
2197+
id: "openai",
2198+
label: "OpenAI",
2199+
autoSelectOrder: 1,
2200+
resolveConfig: ({ rawConfig }) => rawConfig,
2201+
isConfigured: () => true,
2202+
createBridge: () => bridge,
2203+
};
2204+
let pullCount = 0;
2205+
const runtime = {
2206+
nodes: {
2207+
invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
2208+
if (params?.action === "pullAudio") {
2209+
pullCount += 1;
2210+
if (pullCount === 1) {
2211+
throw new Error("transient node timeout");
2212+
}
2213+
if (pullCount === 2) {
2214+
return { bridgeId: "bridge-1", base64: Buffer.from([5, 4, 3]).toString("base64") };
2215+
}
2216+
await new Promise((resolve) => setTimeout(resolve, 1_000));
2217+
return { bridgeId: "bridge-1" };
2218+
}
2219+
return { ok: true };
2220+
}),
2221+
},
2222+
};
2223+
2224+
const handle = await startNodeRealtimeAudioBridge({
2225+
config: resolveGoogleMeetConfig({
2226+
realtime: { provider: "openai", model: "gpt-realtime" },
2227+
}),
2228+
fullConfig: {} as never,
2229+
runtime: runtime as never,
2230+
meetingSessionId: "meet-1",
2231+
nodeId: "node-1",
2232+
bridgeId: "bridge-1",
2233+
logger: noopLogger,
2234+
providers: [provider],
2235+
});
2236+
2237+
await vi.waitFor(() => {
2238+
expect(sendAudio).toHaveBeenCalledWith(Buffer.from([5, 4, 3]));
2239+
});
2240+
expect(bridge.close).not.toHaveBeenCalled();
2241+
expect(handle.getHealth()).toMatchObject({
2242+
audioInputActive: true,
2243+
lastInputBytes: 3,
2244+
consecutiveInputErrors: 0,
2245+
});
2246+
2247+
await handle.stop();
2248+
});
2249+
2250+
it("stops paired-node realtime audio after repeated input pull failures", async () => {
2251+
const bridge = {
2252+
connect: vi.fn(async () => {}),
2253+
sendAudio: vi.fn(),
2254+
setMediaTimestamp: vi.fn(),
2255+
submitToolResult: vi.fn(),
2256+
acknowledgeMark: vi.fn(),
2257+
close: vi.fn(),
2258+
triggerGreeting: vi.fn(),
2259+
isConnected: vi.fn(() => true),
2260+
};
2261+
const provider: RealtimeVoiceProviderPlugin = {
2262+
id: "openai",
2263+
label: "OpenAI",
2264+
autoSelectOrder: 1,
2265+
resolveConfig: ({ rawConfig }) => rawConfig,
2266+
isConfigured: () => true,
2267+
createBridge: () => bridge,
2268+
};
2269+
const runtime = {
2270+
nodes: {
2271+
invoke: vi.fn(async ({ params }: { params?: { action?: string } }) => {
2272+
if (params?.action === "pullAudio") {
2273+
throw new Error("node invoke timeout");
2274+
}
2275+
return { ok: true };
2276+
}),
2277+
},
2278+
};
2279+
2280+
const handle = await startNodeRealtimeAudioBridge({
2281+
config: resolveGoogleMeetConfig({
2282+
realtime: { provider: "openai", model: "gpt-realtime" },
2283+
}),
2284+
fullConfig: {} as never,
2285+
runtime: runtime as never,
2286+
meetingSessionId: "meet-1",
2287+
nodeId: "node-1",
2288+
bridgeId: "bridge-1",
2289+
logger: noopLogger,
2290+
providers: [provider],
2291+
});
2292+
2293+
await vi.waitFor(
2294+
() => {
2295+
expect(bridge.close).toHaveBeenCalled();
2296+
},
2297+
{ timeout: 3_000 },
2298+
);
2299+
expect(handle.getHealth()).toMatchObject({
2300+
bridgeClosed: true,
2301+
consecutiveInputErrors: 5,
2302+
lastInputError: "node invoke timeout",
2303+
});
2304+
expect(runtime.nodes.invoke).toHaveBeenCalledWith(
2305+
expect.objectContaining({
2306+
nodeId: "node-1",
2307+
command: "googlemeet.chrome",
2308+
params: { action: "stop", bridgeId: "bridge-1" },
2309+
timeoutMs: 5_000,
2310+
}),
2311+
);
2312+
});
2313+
2314+
it("exposes node-host list and stop-by-url bridge actions", async () => {
2315+
const listed = JSON.parse(
2316+
await handleGoogleMeetNodeHostCommand(
2317+
JSON.stringify({ action: "list", url: "https://meet.google.com/abc-defg-hij" }),
2318+
),
2319+
);
2320+
expect(listed).toEqual({ bridges: [] });
2321+
2322+
await expect(
2323+
handleGoogleMeetNodeHostCommand(JSON.stringify({ action: "stopByUrl" })),
2324+
).rejects.toThrow("url required");
2325+
});
21712326
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { EventEmitter } from "node:events";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
type MockChild = EventEmitter & {
5+
exitCode: number | null;
6+
signalCode: NodeJS.Signals | null;
7+
kill: ReturnType<typeof vi.fn>;
8+
stdout?: EventEmitter;
9+
stderr?: EventEmitter;
10+
stdin?: { write: ReturnType<typeof vi.fn> };
11+
};
12+
13+
const children: MockChild[] = [];
14+
15+
vi.mock("node:child_process", async (importOriginal) => {
16+
const actual = await importOriginal<typeof import("node:child_process")>();
17+
return {
18+
...actual,
19+
spawnSync: vi.fn(() => ({
20+
status: 0,
21+
stdout: "BlackHole 2ch",
22+
stderr: "",
23+
})),
24+
spawn: vi.fn(() => {
25+
const child = Object.assign(new EventEmitter(), {
26+
exitCode: null,
27+
signalCode: null,
28+
kill: vi.fn((signal?: NodeJS.Signals) => {
29+
child.signalCode = signal ?? "SIGTERM";
30+
return true;
31+
}),
32+
stdout: new EventEmitter(),
33+
stderr: new EventEmitter(),
34+
stdin: { write: vi.fn() },
35+
}) as MockChild;
36+
children.push(child);
37+
return child;
38+
}),
39+
};
40+
});
41+
42+
describe("google-meet node host bridge sessions", () => {
43+
it("lists active bridge sessions and hides closed sessions", async () => {
44+
const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js");
45+
const originalPlatform = process.platform;
46+
children.length = 0;
47+
48+
Object.defineProperty(process, "platform", { configurable: true, value: "darwin" });
49+
try {
50+
const start = JSON.parse(
51+
await handleGoogleMeetNodeHostCommand(
52+
JSON.stringify({
53+
action: "start",
54+
url: "https://meet.google.com/abc-defg-hij?authuser=1",
55+
mode: "realtime",
56+
launch: false,
57+
audioInputCommand: ["mock-rec"],
58+
audioOutputCommand: ["mock-play"],
59+
}),
60+
),
61+
);
62+
63+
expect(start).toMatchObject({
64+
audioBridge: { type: "node-command-pair" },
65+
bridgeId: expect.any(String),
66+
});
67+
68+
const activeList = JSON.parse(
69+
await handleGoogleMeetNodeHostCommand(
70+
JSON.stringify({
71+
action: "list",
72+
url: "https://meet.google.com/abc-defg-hij",
73+
mode: "realtime",
74+
}),
75+
),
76+
);
77+
78+
expect(activeList.bridges).toHaveLength(1);
79+
expect(activeList.bridges[0]).toMatchObject({
80+
bridgeId: start.bridgeId,
81+
closed: false,
82+
mode: "realtime",
83+
url: "https://meet.google.com/abc-defg-hij?authuser=1",
84+
});
85+
86+
children[1]?.emit("exit", 0, null);
87+
88+
const afterExitList = JSON.parse(
89+
await handleGoogleMeetNodeHostCommand(
90+
JSON.stringify({
91+
action: "list",
92+
url: "https://meet.google.com/abc-defg-hij",
93+
mode: "realtime",
94+
}),
95+
),
96+
);
97+
98+
expect(afterExitList).toEqual({ bridges: [] });
99+
100+
const stopped = JSON.parse(
101+
await handleGoogleMeetNodeHostCommand(
102+
JSON.stringify({
103+
action: "stopByUrl",
104+
url: "https://meet.google.com/abc-defg-hij",
105+
mode: "realtime",
106+
}),
107+
),
108+
);
109+
110+
expect(stopped).toEqual({ ok: true, stopped: 0 });
111+
} finally {
112+
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
113+
}
114+
});
115+
});

0 commit comments

Comments
 (0)