fix: use sync execFileSync in macOS mic probe to avoid spawn EBADF

Both async variants (execFile and Promise-wrapped spawn) fail with spawn EBADF
(errno -9) in a Finder-launched packaged .app, every detector tick. execFileSync
uses the sync child_process path, already proven in this packaged app
(main.ts uses it at startup). parseAssertions and tests unchanged.
This commit is contained in:
Prakhar Pandey 2026-06-16 20:29:07 +05:30
parent b5fbfd9172
commit 2ba122c375

View file

@ -1,82 +1,20 @@
import { spawn } from "node:child_process";
import { execFileSync } from "node:child_process";
import type { MicProbe, MicUser } from "./types.js";
// 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:
//
// 1. `pmset -g assertions` — apps in a video call almost always hold a
// PreventUserIdleDisplaySleep wake-lock to keep the screen on. Strong
// proxy for "active call." False positives: video playback (YouTube,
// Netflix) — Phase 2's tab-title check filters those out for browsers.
//
// 2. `lsof | grep coreaudiod` — clients connected to coreaudiod. Noisy and
// doesn't always include the mic user, so we prefer pmset as primary.
//
// Output format from `pmset -g assertions`:
// pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..."
// pid 664(Google Chrome): [0x...] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"
//
// We key on two assertion types:
// - PreventUserIdleDisplaySleep — native meeting apps keep the screen on
// during a call. We deliberately do NOT match PreventUserIdleSystemSleep,
// which is held by `caffeinate`, `powerd`, downloads, etc. (noise).
// - NoIdleSleepAssertion — browsers (Chrome/Arc/Safari/etc.) hold this with
// the reason "WebRTC has active PeerConnections" whenever a WebRTC call is
// live (Google Meet, Zoom web, Teams web, Discord, Slack huddles). This is
// the most reliable browser-meeting signal. False positives (e.g. a WebRTC
// 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;
const PMSET_TIMEOUT_MS = 4_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<string> {
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()}`));
}
});
// Sync execFileSync, NOT async execFile/spawn. In a Finder-launched packaged
// .app the async ChildProcess.spawn path fails with `spawn EBADF` (errno -9)
// every detector tick. The synchronous path avoids it and is already proven
// in this exact packaged app -- main.ts uses execFileSync at startup.
function runPmsetAssertions(): string {
return execFileSync("/usr/bin/pmset", ["-g", "assertions"], {
timeout: PMSET_TIMEOUT_MS,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
}
@ -84,21 +22,15 @@ export class MacOsMicProbe implements MicProbe {
async probe(): Promise<MicUser[]> {
let stdout: string;
try {
stdout = await runPmsetAssertions();
stdout = runPmsetAssertions();
} catch (err) {
console.error("[MeetingDetect] macOS probe failed:", err);
return [];
}
return parseAssertions(stdout);
}
}
/**
* Parse `pmset -g assertions` stdout into the distinct processes holding a
* meeting-relevant assertion. Pure (no OS calls) so it's unit-testable against
* captured pmset output. One entry per pid; the first matching line wins.
*/
export function parseAssertions(stdout: string): MicUser[] {
const seen = new Map<number, MicUser>();
for (const line of stdout.split("\n")) {