From b5fbfd917246c555a18149d3a644d0117a9f1339 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Mon, 15 Jun 2026 21:58:57 +0530 Subject: [PATCH] fix: prevent EBADF in macOS mic probe on packaged builds The pmset probe used execFile, which in a packaged .app launched from Finder (no controlling terminal) has no valid stdin file descriptor. execFile wires the child's stdio to that invalid fd, and because the probe fires repeatedly from the detector's background poll loop, the spawn fails with `EBADF` (errno -9). This never reproduces in dev, where launching from a terminal provides a valid stdin. Switch to spawn with stdio ['ignore', 'pipe', 'pipe'] so the child's stdin points at /dev/null and no invalid parent descriptor is ever inherited. parseAssertions (and its tests) are unchanged. --- .../main/src/meeting-detect/probe-macos.ts | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.ts index 4759880c..8917acdf 100644 --- a/apps/x/apps/main/src/meeting-detect/probe-macos.ts +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.ts @@ -1,9 +1,6 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import { spawn } from "node:child_process"; import type { MicProbe, MicUser } from "./types.js"; -const execFileAsync = promisify(execFile); - // macOS doesn't expose a public "who is using the mic right now" API. Two // pragmatic signals we can read from a shell without a native helper: // @@ -30,14 +27,64 @@ const execFileAsync = promisify(execFile); // YouTube tab) are filtered downstream by browser-match's tab-title check. const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdleDisplaySleep|NoIdleSleepAssertion)/; +const PMSET_TIMEOUT_MS = 10_000; + +// Run `pmset -g assertions` and resolve its stdout. +// +// We use spawn (not execFile) with stdin explicitly set to "ignore" because in +// a packaged .app launched from Finder — rather than a terminal — the main +// process has no valid stdin file descriptor. execFile would try to wire the +// child's stdio to that invalid fd, and since this runs repeatedly from the +// detector's background poll loop, the spawn fails with `EBADF` (errno -9). +// Setting stdio to ['ignore', 'pipe', 'pipe'] points the child's stdin at +// /dev/null, so no invalid descriptor is ever inherited. (This never surfaces +// in dev because launching from a terminal provides a valid stdin.) +function runPmsetAssertions(): Promise { + return new Promise((resolve, reject) => { + const child = spawn("/usr/bin/pmset", ["-g", "assertions"], { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill("SIGKILL"); + reject(new Error(`pmset timed out after ${PMSET_TIMEOUT_MS}ms`)); + }, PMSET_TIMEOUT_MS); + + child.stdout?.on("data", (chunk) => { stdout += chunk; }); + child.stderr?.on("data", (chunk) => { stderr += chunk; }); + + child.on("error", (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`pmset exited with code ${code}: ${stderr.trim()}`)); + } + }); + }); +} + export class MacOsMicProbe implements MicProbe { async probe(): Promise { let stdout: string; try { - const result = await execFileAsync("/usr/bin/pmset", ["-g", "assertions"], { - timeout: 10_000, - }); - stdout = result.stdout; + stdout = await runPmsetAssertions(); } catch (err) { console.error("[MeetingDetect] macOS probe failed:", err); return [];