mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-28 04:24:51 +02:00
[eric] voice: reap fn-watchers stranded by a crash, so a dead session can't keep tapping the keyboard
This commit is contained in:
+38
-2
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user