[eric] voice: reap fn-watchers stranded by a crash, so a dead session can't keep tapping the keyboard

This commit is contained in:
ciregenz
2026-08-07 15:31:09 -07:00
parent ec0d706a80
commit c0117d817b
2 changed files with 90 additions and 2 deletions
+38 -2
View File
@@ -133,6 +133,7 @@ function installVoiceHotkey(getMainWindow) {
// ---- fn/Globe primary tier (macOS): the native watcher, since no JS tap can see keycode 63 ----
let fnProc = null;
let quitReaperWired = false;
const startFnWatcher = () => {
if (process.platform !== 'darwin' || combo.special !== 'fn' || fnProc) return;
resolveFnWatcherBinary((bin) => {
@@ -141,7 +142,21 @@ function installVoiceHotkey(getMainWindow) {
startFnWatcherWith(bin);
});
};
// Kill fn-watchers left by a previous OpenSwarm that died badly. will-quit is the ONLY thing that
// reaps ours, and it never runs on a crash or a force-quit, so each bad exit strands a process
// holding a GLOBAL keyboard tap forever (one was found alive after 2h35m). They accumulate, and
// every extra one re-sends fn, so dictation double-toggles. We are a single-instance app about to
// spawn our own, which makes this the one moment any other fn-watcher is provably not ours.
const sweepStrayFnWatchers = (bin) => {
try {
const out = spawnSync('ps', ['-eo', 'pid=,args='], { encoding: 'utf8', timeout: 4000 });
for (const pid of strayFnWatcherPids(String((out && out.stdout) || ''), bin, process.pid)) {
try { process.kill(pid, 'SIGKILL'); console.log('[voice] reaped stray fn watcher', pid); } catch (_) {}
}
} catch (_) { /* a machine where ps is restricted must still arm the watcher */ }
};
const startFnWatcherWith = (bin) => {
sweepStrayFnWatchers(bin);
try {
fnProc = spawn(bin, [], { stdio: ['ignore', 'pipe', 'ignore'] });
} catch (e) {
@@ -174,7 +189,10 @@ function installVoiceHotkey(getMainWindow) {
fnProven = false;
registerVoiceShortcut();
});
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
if (!quitReaperWired) {
quitReaperWired = true;
app.on('will-quit', () => { try { fnProc && fnProc.kill('SIGKILL'); } catch (_) {} });
}
console.log('[voice] fn watcher armed (awaiting first event to prove Input Monitoring)');
// macOS's own Globe-key action (emoji picker by default) fires on a quick fn tap alongside us;
// tell the renderer once so it can point the user at "Press Globe key to: Do Nothing".
@@ -330,4 +348,22 @@ function installVoiceHotkey(getMainWindow) {
});
}
module.exports = { installVoiceHotkey };
/**
* PIDs of fn-watcher processes that are NOT this app's, given `ps -eo pid=,args=` output.
*
* Matched on the absolute binary path so an unrelated program never matches, and our own pid is
* excluded. Pure so the selection can be tested; the killing stays at the call site.
*/
function strayFnWatcherPids(psOutput, binPath, selfPid) {
const pids = [];
if (!binPath) return pids;
for (const line of String(psOutput || '').split('\n')) {
if (line.indexOf(binPath) < 0) continue;
const pid = parseInt(line.trim().split(/\s+/)[0], 10);
if (!Number.isInteger(pid) || pid <= 1 || pid === selfPid) continue;
pids.push(pid);
}
return pids;
}
module.exports = { installVoiceHotkey, strayFnWatcherPids };
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const assert = require('assert');
const { strayFnWatcherPids } = require('./voiceHotkey');
// The fn watcher holds a GLOBAL keyboard tap. Its only reaper is app.on('will-quit'), which never
// runs on a crash or a force-quit, so bad exits strand one forever (found alive after 2h35m on a dev
// box, with a second live one, which means fn fires twice and dictation double-toggles). We sweep at
// spawn, the one moment any other fn-watcher is provably not ours. Every case here is about NOT
// killing something that isn't a stray, because this sends SIGKILL.
const BIN = '/Users/x/Library/Application Support/openswarm/fn-watcher-bin/fn-watcher';
function ps(lines) { return lines.join('\n'); }
{ // the actual field case: one orphan, one of ours
const out = ps([
` 16541 ${BIN}`,
` 47069 ${BIN}`,
' 1234 /usr/bin/some-other-app',
]);
assert.deepEqual(strayFnWatcherPids(out, BIN, 47069), [16541], 'must reap the orphan, keep ours');
}
{ // nothing stray
assert.deepEqual(strayFnWatcherPids(ps([` 47069 ${BIN}`]), BIN, 47069), []);
}
{ // never match an unrelated binary that merely has a similar name
const out = ps([' 900 /opt/other/fn-watcher', ' 901 /usr/bin/fn-watcher-clone']);
assert.deepEqual(strayFnWatcherPids(out, BIN, 1), [], 'path match must be exact, not by basename');
}
{ // pid 1 is never a candidate, whatever ps says
assert.deepEqual(strayFnWatcherPids(ps([` 1 ${BIN}`]), BIN, 999), []);
}
{ // a missing binary path must never turn into "kill everything"
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), '', 999), []);
assert.deepEqual(strayFnWatcherPids(ps([` 16541 ${BIN}`]), null, 999), []);
}
{ // restricted/absent ps output degrades to a no-op
assert.deepEqual(strayFnWatcherPids('', BIN, 999), []);
assert.deepEqual(strayFnWatcherPids(null, BIN, 999), []);
}
{ // several strays accumulated across several bad exits
const out = ps([` 100 ${BIN}`, ` 200 ${BIN}`, ` 300 ${BIN}`, ` 400 ${BIN}`]);
assert.deepEqual(strayFnWatcherPids(out, BIN, 300), [100, 200, 400]);
}
console.log('voiceHotkeyStray: all assertions passed');