Skip to content

Commit 53401b2

Browse files
Hardening v2.7claude
andcommitted
fix(audio): stop the system-audio tap poisoning the mic picker, and revive two dead mic fallbacks
Production 2.8.7 report: every meeting failed with Microphone failed to start (Failed: Input device 'NativelySystemAudioTap' not found. Available devices: iPhone Microphone, MacBook Air Microphone) NativelySystemAudioTap is Natively's OWN CoreAudio aggregate (speaker/ core_audio.rs), not a microphone. It was sitting in the launcher's preferredInputDeviceId. Three independent defects put it there and then let it take the mic channel down for the whole meeting. (a) The tap was offerable as a microphone. kAudioAggregateDeviceIsPrivateKey hides the aggregate from OTHER processes, not from ours: cpal's host.input_devices(), running inside the Natively main process, enumerates it for as long as the tap is live — sorted between the real mics. Opening Settings during a meeting therefore offered Natively's own tap as an input, and one click persisted it. The tap does not exist when the mic channel starts, so every LATER meeting died. Verified by probe against live CoreAudio, not inferred. (b) resolve_input_device hard-errors with no fallback, and the error arrives too late for the fallback ladder that exists. reconfigureAudio now resolves the saved input against the live enumeration BEFORE constructing anything (no HAL open, so it cannot light the macOS orange mic indicator) and falls back to the default with a fellBack:true broadcast. That broadcast is what raises the amber "couldn't be opened — using <device> instead" banner whose Reset button already cleared preferredInputDeviceId: the self-heal UI existed and was simply unreachable, because the input path threw instead of broadcasting. (c) Both mic fallback ladders were dead code, and recovery could not re-arm. MicrophoneCapture is deliberately lazy (constructing natively lights the mic indicator), so `new MicrophoneCapture(id)` cannot throw for a missing device — the native handle is built inside start(). Every `try { new MicrophoneCapture(id) } catch { new MicrophoneCapture() }` was therefore unreachable, and each recovery attempt retried the identical dead id. Worse, that retry's start() emits 'error' synchronously before throwing, re-entering the handler while _micRecoveryInProgress is true, so the guard dropped it: attempts froze at 1 and the 3-attempt terminal banner could never fire either. Fixed by falling back on the surface that actually throws (new MicrophoneCapture.retargetDevice() — no destroy/recreate, so the wireMicCapture wiring survives and no teardown races a fresh device open) and by driving the three attempts in a loop where failure is observed directly. Trigger is macOS-only; defects (b) and (c) are not. microphone.rs has no cfg gates and MicrophoneCapture.ts / setupMicRecoveryHandler have no platform branches, so any stale saved mic on Windows hit the same dead fallback — and WASAPI renumbering ("(2- USB Audio Device)", the case normalize_device_name exists for) makes that more common there, not less. The Rust Err is deliberately NOT loosened: resolve_input_device is shared by both backends and that behaviour cannot be exercised from macOS. New electron/audio/inputDeviceSelection.mjs is the single choke point (filter + Rust-mirrored availability resolver), imported by AudioDevices, main.ts and App.tsx; App.tsx also discards an already-poisoned preference on meeting start. Validation: Tested physically on macOS — (a) + the gate, probed against live CoreAudio with the tap running and stopped Covered by automated macOS branch tests — (c), via the fake-native harness Reviewed but not executed on Windows Requires physical Windows verification Commands: npm run typecheck:electron, npm run typecheck:ts7, npm test (8465 tests, 8400 pass; the 2 OllamaManagerGating failures reproduce at HEAD in a clean worktree — a local Ollama server is running), audio + mic-recovery suites 421/421. Both new suites mutation-probed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EHzx467QuEy3gNxZFU9uts
1 parent 8e7f703 commit 53401b2

8 files changed

Lines changed: 707 additions & 95 deletions

electron/audio/AudioDevices.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { loadNativeModule } from './nativeModuleLoader';
2+
import { filterSelectableInputDevices } from './inputDeviceSelection.mjs';
23

34
// NativeModule may be null if the Rust binary isn't built yet (new clone without `npm run build:native`).
45
// All methods below handle this gracefully by returning empty arrays.
@@ -17,7 +18,16 @@ export class AudioDevices {
1718
return [];
1819
}
1920
try {
20-
return getInputDevices();
21+
// Natively's own system-audio tap is an aggregate device that cpal
22+
// enumerates as an INPUT while a meeting is capturing (private
23+
// aggregates are hidden from other processes, not from ours). Left
24+
// unfiltered it appears in the mic dropdown, gets persisted as
25+
// preferredInputDeviceId, and then breaks every later meeting —
26+
// the tap does not exist yet when the mic channel starts. Filtering
27+
// at this single choke point also keeps the I/O-conflict fallback,
28+
// the built-in-mic lookup and the last-resort candidate ladder in
29+
// main.ts from ever selecting it.
30+
return filterSelectableInputDevices(getInputDevices());
2131
} catch (e) {
2232
console.error('[AudioDevices] Failed to get input devices:', e);
2333
return [];

electron/audio/MicrophoneCapture.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,35 @@ export class MicrophoneCapture extends EventEmitter {
8484
return 0;
8585
}
8686

87+
/**
88+
* Re-point this wrapper at a different input device.
89+
*
90+
* Exists for the "saved device is gone" retry: because init is LAZY, a bad
91+
* device id cannot be detected until start() constructs the native monitor
92+
* and throws. At that moment the wrapper holds no native handle (the
93+
* construction-failure branch in start() never assigns this.monitor), so
94+
* the cheapest correct retry is to re-target THIS instance and start again
95+
* — no destroy/recreate, so the caller keeps its wireMicCapture() wiring
96+
* and there is no teardown racing a fresh device open on the HAL.
97+
*
98+
* Deliberately refuses to run on a live wrapper: swapping deviceId while a
99+
* cpal stream is open would silently desync this.deviceId from the device
100+
* actually being captured.
101+
*/
102+
public retargetDevice(deviceId?: string | null): void {
103+
if (this.monitor || this.isRecording) {
104+
throw new Error(
105+
'[MicrophoneCapture] retargetDevice() requires an inactive wrapper with no native monitor',
106+
);
107+
}
108+
this.deviceId = deviceId || null;
109+
// A wrapper that has never captured successfully must not re-open the
110+
// mic during stop()'s post-teardown pre-warm (same rule start()'s
111+
// failure path enforces).
112+
this.preWarmEnabled = false;
113+
console.log(`[MicrophoneCapture] Re-targeted to device: ${this.deviceId || 'default'}`);
114+
}
115+
87116
/**
88117
* Start capturing microphone audio
89118
*/
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Regression tests for the "Input device 'NativelySystemAudioTap' not found"
2+
// meeting failure (2026-08-26, v2.8.7 production).
3+
//
4+
// Chain: speaker/core_audio.rs builds a PRIVATE CoreAudio aggregate named
5+
// NativelySystemAudioTap to capture system audio. "Private" hides it from other
6+
// processes, NOT from ours — cpal's host.input_devices() enumerates it inside
7+
// the Natively main process while the tap is live, so it appeared in the
8+
// microphone dropdown and could be persisted as preferredInputDeviceId. The mic
9+
// channel starts BEFORE the tap is created, so every later meeting resolved a
10+
// device that did not exist, and Rust's resolve_input_device() hard-errors with
11+
// no default fallback.
12+
//
13+
// These are behavioural tests against the real module (no source assertions):
14+
// electron/audio/inputDeviceSelection.mjs is the single choke point that the
15+
// picker (AudioDevices), the pipeline (main.ts) and the launcher (App.tsx) all
16+
// share.
17+
18+
import { test } from 'node:test';
19+
import assert from 'node:assert/strict';
20+
import { readFileSync } from 'node:fs';
21+
import path from 'node:path';
22+
import { fileURLToPath } from 'node:url';
23+
24+
import {
25+
INTERNAL_CAPTURE_DEVICE_NAMES,
26+
filterSelectableInputDevices,
27+
isInternalCaptureDevice,
28+
normalizeDeviceName,
29+
resolveRequestedInputDevice,
30+
} from '../inputDeviceSelection.mjs';
31+
32+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
33+
34+
// The exact list the failing production machine enumerated, plus the tap in the
35+
// position cpal actually returned it (between the two real mics — which is why
36+
// it was plausible to click).
37+
const DEVICES_WITH_TAP = [
38+
{ id: 'default', name: 'Default Microphone' },
39+
{ id: 'iPhone Microphone', name: 'iPhone Microphone' },
40+
{ id: 'NativelySystemAudioTap', name: 'NativelySystemAudioTap' },
41+
{ id: 'MacBook Air Microphone', name: 'MacBook Air Microphone' },
42+
];
43+
44+
const DEVICES_WITHOUT_TAP = [
45+
{ id: 'default', name: 'Default Microphone' },
46+
{ id: 'iPhone Microphone', name: 'iPhone Microphone' },
47+
{ id: 'MacBook Air Microphone', name: 'MacBook Air Microphone' },
48+
];
49+
50+
test('the system-audio tap is never offered as a selectable microphone', () => {
51+
const selectable = filterSelectableInputDevices(DEVICES_WITH_TAP);
52+
assert.deepEqual(
53+
selectable.map(d => d.id),
54+
['default', 'iPhone Microphone', 'MacBook Air Microphone'],
55+
'BUG: NativelySystemAudioTap is back in the mic picker. Selecting it poisons ' +
56+
'preferredInputDeviceId and breaks every subsequent meeting.',
57+
);
58+
});
59+
60+
test('filtering keeps real devices untouched and tolerates junk input', () => {
61+
assert.deepEqual(filterSelectableInputDevices(DEVICES_WITHOUT_TAP), DEVICES_WITHOUT_TAP);
62+
assert.deepEqual(filterSelectableInputDevices(null), []);
63+
assert.deepEqual(filterSelectableInputDevices(undefined), []);
64+
assert.deepEqual(filterSelectableInputDevices([null, undefined]), []);
65+
});
66+
67+
test('isInternalCaptureDevice matches case and dash variants, not real mics', () => {
68+
assert.equal(isInternalCaptureDevice('NativelySystemAudioTap'), true);
69+
assert.equal(isInternalCaptureDevice('nativelysystemaudiotap'), true);
70+
assert.equal(isInternalCaptureDevice(' NativelySystemAudioTap '), true);
71+
assert.equal(isInternalCaptureDevice('MacBook Air Microphone'), false);
72+
assert.equal(isInternalCaptureDevice(''), false);
73+
assert.equal(isInternalCaptureDevice(null), false);
74+
assert.equal(isInternalCaptureDevice(undefined), false);
75+
});
76+
77+
test('a stored tap id resolves as MISSING so callers fall back to default', () => {
78+
// This is the production failure: the preference survives, but the tap is not
79+
// in the list at mic-start time because it has not been created yet.
80+
const resolution = resolveRequestedInputDevice('NativelySystemAudioTap', DEVICES_WITHOUT_TAP);
81+
assert.equal(resolution.status, 'missing');
82+
assert.deepEqual(resolution.available, ['iPhone Microphone', 'MacBook Air Microphone']);
83+
});
84+
85+
test('no preference resolves to default without consulting the list', () => {
86+
for (const value of [null, undefined, '', ' ', 'default', 'DEFAULT', ' Default ']) {
87+
assert.equal(
88+
resolveRequestedInputDevice(value, DEVICES_WITHOUT_TAP).status,
89+
'default',
90+
`expected "${value}" to mean "system default"`,
91+
);
92+
}
93+
});
94+
95+
test('the synthetic "default" row is never a match candidate', () => {
96+
// Rust's host.input_devices() does not return it — list_input_devices()
97+
// prepends it. Matching it here would let a stored "Default Microphone"
98+
// resolve in JS and then fail in Rust.
99+
const resolution = resolveRequestedInputDevice('Default Microphone', DEVICES_WITHOUT_TAP);
100+
assert.equal(resolution.status, 'missing');
101+
assert.ok(!resolution.available.includes('Default Microphone'));
102+
});
103+
104+
test('the synthetic default ID resolves as default, never as missing', () => {
105+
// main.ts's I/O-conflict and HFP auto-switches pick a replacement mic out of
106+
// AudioDevices.getInputDevices(), whose first row is the synthetic
107+
// { id: 'default' }. They pass its .id through normalizeDeviceId(), which maps
108+
// 'default' -> undefined, so the availability gate is skipped entirely. This
109+
// pins the second line of defence: even if 'default' DID reach the gate it
110+
// must not be reported as an unavailable device, or the auto-switch would
111+
// raise an amber banner naming "default" as missing.
112+
assert.equal(resolveRequestedInputDevice('default', DEVICES_WITHOUT_TAP).status, 'default');
113+
assert.equal(resolveRequestedInputDevice('default', []).status, 'default');
114+
});
115+
116+
test('resolution tiers mirror Rust resolve_input_device (exact < case < fuzzy)', () => {
117+
const devices = [{ id: 'MacBook Air Microphone', name: 'MacBook Air Microphone' }];
118+
119+
const exact = resolveRequestedInputDevice('MacBook Air Microphone', devices);
120+
assert.equal(exact.status, 'matched');
121+
assert.equal(exact.tier, 0);
122+
123+
const caseInsensitive = resolveRequestedInputDevice('macbook air microphone', devices);
124+
assert.equal(caseInsensitive.status, 'matched');
125+
assert.equal(caseInsensitive.tier, 1);
126+
127+
// WASAPI index prefix — the case normalize_device_name exists for.
128+
const fuzzy = resolveRequestedInputDevice(
129+
'(2- USB Audio Device)',
130+
[{ id: 'USB Audio Device', name: 'USB Audio Device' }],
131+
);
132+
assert.equal(fuzzy.status, 'matched');
133+
assert.equal(fuzzy.tier, 2);
134+
});
135+
136+
test('an exact match wins over a fuzzy one regardless of enumeration order', () => {
137+
const devices = [
138+
{ id: '(2- USB Audio Device)', name: '(2- USB Audio Device)' },
139+
{ id: 'USB Audio Device', name: 'USB Audio Device' },
140+
];
141+
const resolution = resolveRequestedInputDevice('USB Audio Device', devices);
142+
assert.equal(resolution.status, 'matched');
143+
assert.equal(resolution.tier, 0);
144+
assert.equal(resolution.id, 'USB Audio Device');
145+
});
146+
147+
test('normalizeDeviceName mirrors the Rust implementation', () => {
148+
assert.equal(normalizeDeviceName('(2- USB Audio Device)'), 'usb audio device');
149+
assert.equal(normalizeDeviceName('AirPods Pro – Hands-Free'), 'airpods pro - hands-free');
150+
assert.equal(normalizeDeviceName(' AirPods Pro '), 'airpods pro');
151+
assert.equal(normalizeDeviceName(''), '');
152+
assert.equal(normalizeDeviceName(null), '');
153+
});
154+
155+
test('the internal-device name matches the aggregate Rust actually creates', () => {
156+
// A rename on either side silently re-opens the bug: the picker would stop
157+
// filtering the real device while filtering a name that no longer exists.
158+
const coreAudioSource = readFileSync(
159+
path.resolve(__dirname, '../../../native-module/src/speaker/core_audio.rs'),
160+
'utf8',
161+
);
162+
const match = /let\s+agg_name\s*=\s*cf::String::from_str\("([^"]+)"\)/.exec(coreAudioSource);
163+
assert.ok(match, 'could not find agg_name in native-module/src/speaker/core_audio.rs');
164+
assert.ok(
165+
INTERNAL_CAPTURE_DEVICE_NAMES.includes(match[1]),
166+
`BUG: core_audio.rs creates an aggregate named "${match[1]}" but ` +
167+
`INTERNAL_CAPTURE_DEVICE_NAMES is ${JSON.stringify(INTERNAL_CAPTURE_DEVICE_NAMES)}. ` +
168+
'The mic picker will offer it again.',
169+
);
170+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Regression test for the mic-recovery dead fallback (2026-08-26).
2+
//
3+
// setupMicRecoveryHandler carried
4+
// try { new MicrophoneCapture(savedId) } catch { new MicrophoneCapture() }
5+
// which can never fire: MicrophoneCapture is LAZY (its constructor must not
6+
// touch the HAL, or the macOS orange mic indicator lights outside a meeting),
7+
// so a missing/unopenable device is only detected inside start(), where the
8+
// NATIVE monitor is constructed. Every recovery attempt therefore retried the
9+
// identical dead device id — the production symptom was
10+
// [MicRecovery] Recovery attempt #1 failed: Failed: Input device
11+
// 'NativelySystemAudioTap' not found.
12+
// repeating with no fallback and no terminal banner.
13+
//
14+
// The fix is retargetDevice(): after a failed start the wrapper holds no native
15+
// handle, so the caller re-points THIS instance at the system default and
16+
// starts again — keeping its wireMicCapture() wiring and avoiding a deferred
17+
// teardown racing a fresh device open.
18+
//
19+
// Harness: the fake-native-module injection used by
20+
// MicFailedStartReleasesHandle2026_08_14, against the dist bundle.
21+
import { test } from 'node:test';
22+
import assert from 'node:assert/strict';
23+
import Module from 'node:module';
24+
import path from 'node:path';
25+
import { fileURLToPath, pathToFileURL } from 'node:url';
26+
27+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
28+
const distRoot = path.resolve(__dirname, '../../../dist-electron/electron/audio');
29+
30+
const constructedWith = [];
31+
32+
const fakeNativeModule = {
33+
getHardwareId: () => 'fake',
34+
verifyGumroadKey: async () => 'fake',
35+
getInputDevices: () => [],
36+
getOutputDevices: () => [],
37+
SystemAudioCapture: function () {
38+
return { start() {}, stop() {}, getSampleRate: () => 16000 };
39+
},
40+
// Mirrors Rust resolve_input_device(): an unknown id throws at CONSTRUCTION
41+
// of the native monitor, `null`/default always succeeds.
42+
MicrophoneCapture: function (deviceId) {
43+
constructedWith.push(deviceId ?? null);
44+
if (deviceId === 'NativelySystemAudioTap') {
45+
throw new Error(
46+
"Failed: Input device 'NativelySystemAudioTap' not found. " +
47+
'Available devices: iPhone Microphone, MacBook Air Microphone',
48+
);
49+
}
50+
return {
51+
start() {},
52+
stop() {},
53+
getSampleRate: () => 16000,
54+
getNativeSampleRate: () => 48000,
55+
};
56+
},
57+
};
58+
59+
const origLoad = Module._load;
60+
Module._load = function patched(request) {
61+
if (request === 'electron') {
62+
return { app: { getAppPath: () => '/tmp/fake', isPackaged: false, isReady: () => false } };
63+
}
64+
if (request.endsWith('.node') || request.includes('native-module')) {
65+
return fakeNativeModule;
66+
}
67+
return origLoad.apply(this, arguments);
68+
};
69+
70+
const { MicrophoneCapture } = await import(
71+
pathToFileURL(path.join(distRoot, 'MicrophoneCapture.js')).href
72+
);
73+
74+
test('constructing with a missing device does NOT throw — only start() does', () => {
75+
constructedWith.length = 0;
76+
const cap = new MicrophoneCapture('NativelySystemAudioTap');
77+
cap.on('error', () => {});
78+
79+
assert.equal(
80+
constructedWith.length,
81+
0,
82+
'BUG: the wrapper constructed a native monitor eagerly. Lazy init is what keeps ' +
83+
'the macOS orange mic indicator off outside a meeting — and it is why any ' +
84+
'fallback wrapped around `new MicrophoneCapture(id)` is dead code.',
85+
);
86+
87+
assert.throws(() => cap.start(), /not found/);
88+
assert.deepEqual(constructedWith, ['NativelySystemAudioTap']);
89+
});
90+
91+
test('retargetDevice(null) + start() recovers onto the system default', () => {
92+
constructedWith.length = 0;
93+
const cap = new MicrophoneCapture('NativelySystemAudioTap');
94+
cap.on('error', () => {});
95+
96+
assert.throws(() => cap.start(), /not found/);
97+
98+
// The recovery path: no destroy, no re-wire.
99+
cap.retargetDevice(null);
100+
cap.start();
101+
102+
assert.deepEqual(
103+
constructedWith,
104+
['NativelySystemAudioTap', null],
105+
'BUG: after a failed start the wrapper must retry on the default device. ' +
106+
'Retrying the same id is the loop that left the mic dead for the whole meeting.',
107+
);
108+
});
109+
110+
test('listeners survive the retarget (no destroy/recreate)', () => {
111+
const cap = new MicrophoneCapture('NativelySystemAudioTap');
112+
let errors = 0;
113+
let started = 0;
114+
cap.on('error', () => { errors += 1; });
115+
cap.on('start', () => { started += 1; });
116+
117+
assert.throws(() => cap.start(), /not found/);
118+
assert.equal(errors, 1, 'the failed start must still emit error for the recovery handler');
119+
120+
cap.retargetDevice(null);
121+
cap.start();
122+
assert.equal(started, 1, "BUG: 'start' listener was lost — retarget must not tear the wrapper down");
123+
});
124+
125+
test('retargetDevice refuses to run on a live wrapper', () => {
126+
const cap = new MicrophoneCapture(null);
127+
cap.on('error', () => {});
128+
cap.start();
129+
130+
assert.throws(
131+
() => cap.retargetDevice('MacBook Air Microphone'),
132+
/inactive wrapper/,
133+
'BUG: swapping deviceId under a live cpal stream desyncs the wrapper from the ' +
134+
'device actually being captured.',
135+
);
136+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export interface EnumeratedInputDevice {
2+
id: string;
3+
name: string;
4+
}
5+
6+
export type InputDeviceResolution =
7+
| { status: 'default' }
8+
| { status: 'matched'; id: string; name: string; tier: 0 | 1 | 2 }
9+
| { status: 'missing'; available: string[] };
10+
11+
export const INTERNAL_CAPTURE_DEVICE_NAMES: readonly string[];
12+
13+
export function normalizeDeviceName(value: string | null | undefined): string;
14+
15+
export function isInternalCaptureDevice(idOrName: string | null | undefined): boolean;
16+
17+
export function filterSelectableInputDevices<T extends EnumeratedInputDevice>(
18+
devices: T[] | null | undefined,
19+
): T[];
20+
21+
export function resolveRequestedInputDevice(
22+
requestedId: string | null | undefined,
23+
devices: EnumeratedInputDevice[] | null | undefined,
24+
): InputDeviceResolution;

0 commit comments

Comments
 (0)