From c0117d817bb5e2354f0a90295dbc549c7f99e690 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 15:31:09 -0700 Subject: [PATCH] [eric] voice: reap fn-watchers stranded by a crash, so a dead session can't keep tapping the keyboard --- electron/voiceHotkey.js | 40 ++++++++++++++++++++++-- electron/voiceHotkeyStray.test.js | 52 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 electron/voiceHotkeyStray.test.js diff --git a/electron/voiceHotkey.js b/electron/voiceHotkey.js index a642bd0c..5def232f 100644 --- a/electron/voiceHotkey.js +++ b/electron/voiceHotkey.js @@ -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 }; diff --git a/electron/voiceHotkeyStray.test.js b/electron/voiceHotkeyStray.test.js new file mode 100644 index 00000000..2011fd8d --- /dev/null +++ b/electron/voiceHotkeyStray.test.js @@ -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');