From 3771dafb72f1b9b3cb85f9528211b5a6f108d394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=B4=B5=E8=90=8D0668001030?= Date: Sun, 19 Jul 2026 22:46:42 +0800 Subject: [PATCH 1/4] fix(teams-meetings): keep setup probes within deadline --- .../teams-meetings/src/node-host.test.ts | 67 +++++++++++++++++++ extensions/teams-meetings/src/node-host.ts | 2 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 extensions/teams-meetings/src/node-host.test.ts diff --git a/extensions/teams-meetings/src/node-host.test.ts b/extensions/teams-meetings/src/node-host.test.ts new file mode 100644 index 000000000000..233d3531a365 --- /dev/null +++ b/extensions/teams-meetings/src/node-host.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const spawnSyncMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawnSync: spawnSyncMock }; +}); + +import { handleTeamsMeetingsNodeHostCommand } from "./node-host.js"; + +const successfulProbe = { + pid: 123, + output: [null, "BlackHole 2ch", ""], + stdout: "BlackHole 2ch", + stderr: "", + status: 0, + signal: null, + error: undefined, +}; + +function setupParams() { + return JSON.stringify({ + action: "setup", + audioInputCommand: ["capture"], + audioOutputCommand: ["play"], + }); +} + +describe("Teams meeting node-host prerequisite deadline", () => { + beforeEach(() => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue(successfulProbe); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shares one timeout budget across every prerequisite probe", async () => { + const now = vi.spyOn(Date, "now"); + for (const value of [1_000, 1_000, 4_000, 4_000, 8_000, 8_000]) { + now.mockReturnValueOnce(value); + } + + await expect(handleTeamsMeetingsNodeHostCommand(setupParams())).resolves.toBe( + JSON.stringify({ ok: true }), + ); + + expect( + spawnSyncMock.mock.calls.map((call) => (call[2] as { timeout?: number }).timeout), + ).toEqual([10_000, 7_000, 3_000]); + }); + + it("does not start another probe after the shared deadline expires", async () => { + const now = vi.spyOn(Date, "now"); + for (const value of [1_000, 1_000, 11_000]) { + now.mockReturnValueOnce(value); + } + + await expect(handleTeamsMeetingsNodeHostCommand(setupParams())).rejects.toThrow( + "Configured audio command not found on the node: capture", + ); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/teams-meetings/src/node-host.ts b/extensions/teams-meetings/src/node-host.ts index e9cbb89a42d3..c45c442ea584 100644 --- a/extensions/teams-meetings/src/node-host.ts +++ b/extensions/teams-meetings/src/node-host.ts @@ -9,5 +9,5 @@ export const handleTeamsMeetingsNodeHostCommand = meetingLabel: "Microsoft Teams meeting", defaultAudioInputCommand: teamsMeetingsConfig.defaultAudioInputCommand, defaultAudioOutputCommand: teamsMeetingsConfig.defaultAudioOutputCommand, - sharePrerequisiteDeadline: false, + sharePrerequisiteDeadline: true, }); From e68277c16641581cb75caa01d778e390280a9389 Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Fri, 24 Jul 2026 02:32:51 +0800 Subject: [PATCH 2/4] fix(meetings): report prerequisite deadline timeouts --- .../teams-meetings/src/node-host.test.ts | 2 +- .../zoom-meetings/src/node-host.test.ts | 25 +++++++++++++++++ src/meeting-bot/node-host.ts | 27 ++++++++++++++----- 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/extensions/teams-meetings/src/node-host.test.ts b/extensions/teams-meetings/src/node-host.test.ts index 233d3531a365..a6736f69ba9a 100644 --- a/extensions/teams-meetings/src/node-host.test.ts +++ b/extensions/teams-meetings/src/node-host.test.ts @@ -60,7 +60,7 @@ describe("Teams meeting node-host prerequisite deadline", () => { } await expect(handleTeamsMeetingsNodeHostCommand(setupParams())).rejects.toThrow( - "Configured audio command not found on the node: capture", + "Microsoft Teams meeting audio prerequisite check timed out on the node.", ); expect(spawnSyncMock).toHaveBeenCalledTimes(1); }); diff --git a/extensions/zoom-meetings/src/node-host.test.ts b/extensions/zoom-meetings/src/node-host.test.ts index 74773b565e50..89f83c83a896 100644 --- a/extensions/zoom-meetings/src/node-host.test.ts +++ b/extensions/zoom-meetings/src/node-host.test.ts @@ -9,6 +9,7 @@ import { handleZoomMeetingsNodeHostCommand } from "./node-host.js"; afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + childProcessMocks.spawnSync.mockReset(); }); describe("Zoom meetings node setup", () => { @@ -40,4 +41,28 @@ describe("Zoom meetings node setup", () => { ), ).toEqual([10_000, 4_000, 2_000]); }); + + it("reports a timed-out command probe separately from a missing command", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const timeoutError = Object.assign(new Error("spawnSync /bin/sh ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + childProcessMocks.spawnSync + .mockReturnValueOnce({ status: 0, stderr: "", stdout: "BlackHole 2ch" }) + .mockReturnValueOnce({ status: null, stderr: "", stdout: "", error: timeoutError }); + + await expect( + handleZoomMeetingsNodeHostCommand( + JSON.stringify({ + action: "setup", + audioInputCommand: ["sox"], + audioOutputCommand: ["play"], + }), + ), + ).rejects.toThrow("Zoom meeting audio prerequisite check timed out on the node."); + + expect(childProcessMocks.spawnSync).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 4c46685663ce..72ae17b8235d 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -626,12 +626,19 @@ function readSetupCommand(params: Record, name: string): string } export function createMeetingConfiguredNodeHost(options: MeetingConfiguredNodeHostOptions) { - const commandExists = (command: string, timeoutMs: number): boolean => { + const probeCommand = (command: string, timeoutMs: number): "found" | "missing" | "timed-out" => { const result = spawnSync("/bin/sh", ["-lc", 'command -v "$1" >/dev/null 2>&1', "sh", command], { encoding: "utf8", timeout: timeoutMs, }); - return result.status === 0; + if ( + result.error instanceof Error && + "code" in result.error && + result.error.code === "ETIMEDOUT" + ) { + return "timed-out"; + } + return result.status === 0 ? "found" : "missing"; }; const assertAudioAvailable = ( timeoutMs: number, @@ -666,13 +673,19 @@ export function createMeetingConfiguredNodeHost(options: MeetingConfiguredNodeHo } for (const argv of commands) { const command = argv[0]; - if ( - !command || - (options.sharePrerequisiteDeadline && Date.now() >= deadline) || - !commandExists(command, commandTimeout()) - ) { + if (!command) { throw new Error(`Configured audio command not found on the node: ${command || ""}`); } + if (options.sharePrerequisiteDeadline && Date.now() >= deadline) { + throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`); + } + const probeResult = probeCommand(command, commandTimeout()); + if (probeResult === "timed-out") { + throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`); + } + if (probeResult === "missing") { + throw new Error(`Configured audio command not found on the node: ${command}`); + } } }; const host = createMeetingNodeHost({ ...options, assertAudioAvailable }); From e406b3ee7b420ea125e957fde77f0a94870ee34c Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Fri, 24 Jul 2026 03:05:20 +0800 Subject: [PATCH 3/4] fix(meetings): classify profiler timeouts --- extensions/teams-meetings/src/node-host.test.ts | 17 +++++++++++++++++ src/meeting-bot/node-host.ts | 13 ++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/extensions/teams-meetings/src/node-host.test.ts b/extensions/teams-meetings/src/node-host.test.ts index a6736f69ba9a..6f9b11e33db6 100644 --- a/extensions/teams-meetings/src/node-host.test.ts +++ b/extensions/teams-meetings/src/node-host.test.ts @@ -64,4 +64,21 @@ describe("Teams meeting node-host prerequisite deadline", () => { ); expect(spawnSyncMock).toHaveBeenCalledTimes(1); }); + + it("reports a timed-out profiler separately from a missing audio device", async () => { + const timeoutError = Object.assign(new Error("spawnSync system_profiler ETIMEDOUT"), { + code: "ETIMEDOUT", + }); + spawnSyncMock.mockReturnValueOnce({ + ...successfulProbe, + status: null, + stdout: "", + error: timeoutError, + }); + + await expect(handleTeamsMeetingsNodeHostCommand(setupParams())).rejects.toThrow( + "Microsoft Teams meeting audio prerequisite check timed out on the node.", + ); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 72ae17b8235d..ce0d85a10413 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -625,17 +625,17 @@ function readSetupCommand(params: Record, name: string): string return value as string[]; } +function isSpawnSyncTimeout(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ETIMEDOUT"; +} + export function createMeetingConfiguredNodeHost(options: MeetingConfiguredNodeHostOptions) { const probeCommand = (command: string, timeoutMs: number): "found" | "missing" | "timed-out" => { const result = spawnSync("/bin/sh", ["-lc", 'command -v "$1" >/dev/null 2>&1', "sh", command], { encoding: "utf8", timeout: timeoutMs, }); - if ( - result.error instanceof Error && - "code" in result.error && - result.error.code === "ETIMEDOUT" - ) { + if (isSpawnSyncTimeout(result.error)) { return "timed-out"; } return result.status === 0 ? "found" : "missing"; @@ -657,6 +657,9 @@ export function createMeetingConfiguredNodeHost(options: MeetingConfiguredNodeHo encoding: "utf8", timeout: commandTimeout(), }); + if (isSpawnSyncTimeout(result.error)) { + throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`); + } const stderr = result.stderr ?? (result.error From a45eaf6aee1d5f60a11ae536334f88e3b9e3efa9 Mon Sep 17 00:00:00 2001 From: zhang-guiping Date: Sat, 1 Aug 2026 18:36:00 +0800 Subject: [PATCH 4/4] fix(meeting-bot): dedupe prerequisite command probes --- .../teams-meetings/src/node-host.test.ts | 21 +++++++++++++++++++ src/meeting-bot/node-host.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/extensions/teams-meetings/src/node-host.test.ts b/extensions/teams-meetings/src/node-host.test.ts index 6f9b11e33db6..c93a40116fcd 100644 --- a/extensions/teams-meetings/src/node-host.test.ts +++ b/extensions/teams-meetings/src/node-host.test.ts @@ -7,6 +7,7 @@ vi.mock("node:child_process", async (importOriginal) => { return { ...actual, spawnSync: spawnSyncMock }; }); +import { teamsMeetingsConfig } from "./config.js"; import { handleTeamsMeetingsNodeHostCommand } from "./node-host.js"; const successfulProbe = { @@ -53,6 +54,26 @@ describe("Teams meeting node-host prerequisite deadline", () => { ).toEqual([10_000, 7_000, 3_000]); }); + it("probes the default sox executable only once", async () => { + await expect( + handleTeamsMeetingsNodeHostCommand( + JSON.stringify({ + action: "setup", + audioInputCommand: teamsMeetingsConfig.defaultAudioInputCommand, + audioOutputCommand: teamsMeetingsConfig.defaultAudioOutputCommand, + }), + ), + ).resolves.toBe(JSON.stringify({ ok: true })); + + expect(spawnSyncMock).toHaveBeenCalledTimes(2); + expect(spawnSyncMock.mock.calls[1]?.[1]).toEqual([ + "-lc", + 'command -v "$1" >/dev/null 2>&1', + "sh", + "sox", + ]); + }); + it("does not start another probe after the shared deadline expires", async () => { const now = vi.spyOn(Date, "now"); for (const value of [1_000, 1_000, 11_000]) { diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index ce0d85a10413..56d683673e31 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -674,11 +674,15 @@ export function createMeetingConfiguredNodeHost(options: MeetingConfiguredNodeHo ) { throw new Error("BlackHole 2ch audio device not found on the node."); } + const commandNames = new Set(); for (const argv of commands) { const command = argv[0]; if (!command) { throw new Error(`Configured audio command not found on the node: ${command || ""}`); } + commandNames.add(command); + } + for (const command of commandNames) { if (options.sharePrerequisiteDeadline && Date.now() >= deadline) { throw new Error(`${options.meetingLabel} audio prerequisite check timed out on the node.`); }