From 119c10110f9169b52cf5de870fe30ee8bfa08395 Mon Sep 17 00:00:00 2001 From: TMHSDigital Date: Tue, 16 Jun 2026 18:55:28 -0400 Subject: [PATCH] fix: cap audio device enumeration and give a clear error on hang (#40) The dshow -list_devices probe can block on some setups (a misbehaving audio driver), so list_audio_devices and system-audio recording could hang the full 30s and return the raw "ffmpeg timed out" text. Enumeration now uses a 12s cap, closes the child's stdin (a known hang cause), and surfaces a clear, actionable error. A successful result is cached for the process so a recording does not re-pay the probe; list_audio_devices forces a fresh probe. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 +++++++++ package-lock.json | 4 +-- package.json | 2 +- src/tools/listAudioDevices.ts | 4 ++- src/utils/audioDevices.ts | 55 ++++++++++++++++++++++++++++++----- src/utils/ffmpeg.ts | 12 ++++++++ 6 files changed, 79 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4d33d..cb5048a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,20 @@ project adheres to [Semantic Versioning](https://semver.org/). - ESLint (flat config, typescript-eslint recommended) with a `lint` script, run in CI (#23). Tooling only; not part of the published package. +## [0.8.10] + +### Fixed + +- **Audio device enumeration no longer hangs for 30s with a misleading timeout** + (#40). The dshow `-list_devices` probe can block on some setups (a misbehaving + audio driver), and `list_audio_devices` / system-audio recording surfaced the + raw `ffmpeg timed out after 30s` text. Enumeration now uses a 12s cap, closes + the child's stdin (a known hang cause), and returns a clear "could not + enumerate audio devices" message with a fix hint. A successful result is cached + for the process so an audio recording does not re-pay the probe; the + `list_audio_devices` tool forces a fresh probe so a newly enabled device still + shows up. + ## [0.8.9] ### Fixed diff --git a/package-lock.json b/package-lock.json index 2d159ef..9c3c491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tmhs/screencast-mcp", - "version": "0.8.9", + "version": "0.8.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@tmhs/screencast-mcp", - "version": "0.8.9", + "version": "0.8.10", "license": "CC-BY-NC-ND-4.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", diff --git a/package.json b/package.json index 8dd600c..5746f5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tmhs/screencast-mcp", - "version": "0.8.9", + "version": "0.8.10", "description": "MCP server for Windows screen recording, frame sampling, and minimal ffmpeg edits", "type": "module", "main": "dist/index.js", diff --git a/src/tools/listAudioDevices.ts b/src/tools/listAudioDevices.ts index 96cf434..7d9b084 100644 --- a/src/tools/listAudioDevices.ts +++ b/src/tools/listAudioDevices.ts @@ -18,7 +18,9 @@ export function register(server: McpServer): void { async () => { try { requireFfmpeg(); - const devices = await listDshowAudioDevices(); + // Always re-probe: this is the explicit "show me what's available" call, + // so a device enabled since the last recording must show up. + const devices = await listDshowAudioDevices(true); const loopback = pickLoopbackDevice(devices); return okResponse({ devices, diff --git a/src/utils/audioDevices.ts b/src/utils/audioDevices.ts index f094c15..2d311ff 100644 --- a/src/utils/audioDevices.ts +++ b/src/utils/audioDevices.ts @@ -80,17 +80,56 @@ export function pickLoopbackDevice( ); } +/** + * Device enumeration must not block a recording start for long. The dshow + * `-list_devices` probe hangs on some setups (a misbehaving audio driver, no + * audio hardware), so it is capped well below the normal ffmpeg job timeout. + */ +export const ENUM_TIMEOUT_MS = 12_000; + +const ENUM_FAILED_HINT = + "Enumerating DirectShow audio devices timed out or failed - usually a " + + "misbehaving audio driver, or no audio device present. Record without system " + + "audio, or fix the device, then retry. " + NO_LOOPBACK_HINT; + +// A successful enumeration is cached for the process lifetime so an audio +// recording does not re-pay the cost (or risk the hang) on every start. The +// list_audio_devices tool forces a refresh, so a device enabled mid-session is +// still discoverable. +let cachedDevices: string[] | null = null; + /** Run ffmpeg's device list and return the audio device names. ffmpeg exits * non-zero for the dummy input by design, so the exit code is ignored and the - * names are read from stderr. */ -export async function listDshowAudioDevices(): Promise { + * names are read from stderr. A timeout or spawn failure becomes a clear, + * actionable error rather than the raw "ffmpeg timed out" text. */ +export async function listDshowAudioDevices(forceRefresh = false): Promise { + if (!forceRefresh && cachedDevices) return cachedDevices; const { ffmpeg } = requireFfmpeg(); - const res = await runCapture( - ffmpeg, - ["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], - 30_000, - ); - return parseDshowAudioDevices(res.stderr); + let res; + try { + res = await runCapture( + ffmpeg, + ["-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy"], + ENUM_TIMEOUT_MS, + { closeStdin: true }, + ); + } catch (err) { + // runCapture rejects on timeout (and on spawn error); ffmpeg's by-design + // non-zero exit for the dummy input resolves instead, so reaching here is a + // genuine failure, not the expected exit code. + throw new ScreencastError( + `Could not enumerate audio devices (${(err as Error).message}).`, + ENUM_FAILED_HINT, + ); + } + const devices = parseDshowAudioDevices(res.stderr); + cachedDevices = devices; + return devices; +} + +/** Drop the cached device list (test seam; also lets a caller force a re-probe). */ +export function clearAudioDeviceCache(): void { + cachedDevices = null; } /** Resolve the loopback device to use, throwing a clear, actionable error when diff --git a/src/utils/ffmpeg.ts b/src/utils/ffmpeg.ts index e10698a..38bc337 100644 --- a/src/utils/ffmpeg.ts +++ b/src/utils/ffmpeg.ts @@ -72,15 +72,27 @@ export interface RunResult { stderr: string; } +export interface RunCaptureOptions { + /** + * End the child's stdin immediately. dshow device enumeration + * (`-list_devices`) can block waiting on stdin on some setups; closing it + * removes that hang as a cause. Safe only for jobs that do not read stdin + * (which is every short job that goes through runCapture). + */ + closeStdin?: boolean; +} + /** Run a binary to completion, capturing stdout/stderr. Used for ffprobe and * short ffmpeg edit jobs (not for long-running captures). */ export function runCapture( bin: string, args: string[], timeoutMs = 5 * 60 * 1000, + opts: RunCaptureOptions = {}, ): Promise { return new Promise((resolve, reject) => { const child = spawn(bin, args, { windowsHide: true }); + if (opts.closeStdin) child.stdin?.end(); let stdout = ""; let stderr = ""; const timer = setTimeout(() => {