← back to Desktop Dotbar
Harden ram-sniper toggle + ticket-fetch resilience (TK-12071 test findings)
25e5616650351f6aa4b0b38b06af6d1b213f42ae · 2026-09-23 10:47:35 -0700 · Steve Abrams
Fix #2: resolve the ram-sniper daemon by exact PID (a shell interpreter
executing the script) and kill by PID, replacing the broad `pkill -f
bin/ram-sniper.sh` that SIGTERM'd any process merely mentioning the path
(editors, pgrep shells). See pgrep-f-matches-own-watcher-shell.
Fix #1: keep-last-good in getTickets/refreshTickets, mirroring getDots.
A dead ticket board makes curl return '' which JSON.parse('[]')'d cleanly
to [] and zeroed every count; now an empty/unparseable body returns null
and the last-good snapshot is kept instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i
Files touched
Diff
commit 25e5616650351f6aa4b0b38b06af6d1b213f42ae
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Sep 23 10:47:35 2026 -0700
Harden ram-sniper toggle + ticket-fetch resilience (TK-12071 test findings)
Fix #2: resolve the ram-sniper daemon by exact PID (a shell interpreter
executing the script) and kill by PID, replacing the broad `pkill -f
bin/ram-sniper.sh` that SIGTERM'd any process merely mentioning the path
(editors, pgrep shells). See pgrep-f-matches-own-watcher-shell.
Fix #1: keep-last-good in getTickets/refreshTickets, mirroring getDots.
A dead ticket board makes curl return '' which JSON.parse('[]')'d cleanly
to [] and zeroed every count; now an empty/unparseable body returns null
and the last-good snapshot is kept instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019excZ7L7VE14hbPqKQqH3i
---
server.js | 83 ++++++++++++++++++++++++++++++++++++++++++---------------------
1 file changed, 55 insertions(+), 28 deletions(-)
diff --git a/server.js b/server.js
index a230a25..7faf854 100755
--- a/server.js
+++ b/server.js
@@ -129,12 +129,25 @@ async function arrange() {
}
// ---- RAM Sniper panel: live RAM%/CPU%, top memory hogs, daemon on/off ----
-// Is the priority-throttle daemon running? Path-qualified pattern so pgrep can't
-// self-match the node server (see pgrep-f-matches-own-watcher-shell).
-async function sniperOn() {
- const { stdout } = await run('pgrep', ['-f', 'bin/ram-sniper.sh']);
- return stdout.trim().split(/\s+/).filter(Boolean).length > 0;
+// PIDs of the ACTUAL ram-sniper daemon — a shell interpreter EXECUTING the script
+// (ps shows `/bin/zsh /Users/.../ram-sniper.sh`), NOT an editor with the file open,
+// a pgrep/grep shell that merely mentions the path, or this node server. The old
+// `-f` substring match hit all of those (see pgrep-f-matches-own-watcher-shell) —
+// during testing it SIGTERM'd the caller's own `pgrep -f bin/ram-sniper.sh` shells.
+async function sniperPids() {
+ const { stdout } = await run('ps', ['-axo', 'pid=,command=']);
+ const pids = [];
+ for (const line of stdout.split('\n')) {
+ const m = /^\s*(\d+)\s+(.*)$/.exec(line);
+ if (!m) continue;
+ const pid = parseInt(m[1], 10), cmd = m[2];
+ if (pid === process.pid) continue; // never us
+ // a shell interpreter (^ or /-prefixed) directly followed by a path ending in ram-sniper.sh
+ if (/(^|\/)(?:zsh|bash|dash|sh)\s+\S*ram-sniper\.sh(\s|$)/.test(cmd)) pids.push(pid);
+ }
+ return pids;
}
+async function sniperOn() { return (await sniperPids()).length > 0; }
// Zero-dep stats: system RAM%/CPU% from `top`, top hogs from `ps` (no psutil).
async function getRam() {
const [{ stdout: topOut }, { stdout: memBytes }, { stdout: psOut }, mp] = await Promise.all([
@@ -171,9 +184,15 @@ async function getRam() {
}).filter(Boolean).sort((a, b) => +b.gb - +a.gb).slice(0, 12);
return { updated: Date.now(), ram_pct: Math.round(ramPct), cpu_pct: Math.round(cpu), on: await sniperOn(), hogs };
}
-// Toggle the daemon: kill if running (reversible — it only reniced), else spawn detached.
+// Toggle the daemon: kill the exact daemon PID(s) if running (reversible — it only
+// reniced), else spawn detached. Kills by resolved PID, never `pkill -f`, so it can't
+// SIGTERM an editor or a pgrep shell that merely mentions the script path.
async function toggleSniper() {
- if (await sniperOn()) { await run('pkill', ['-f', 'bin/ram-sniper.sh']); return { on: false }; }
+ const pids = await sniperPids();
+ if (pids.length) {
+ for (const pid of pids) { try { process.kill(pid, 'SIGTERM'); } catch {} }
+ return { on: false };
+ }
try { spawn(SNIPER, [], { detached: true, stdio: 'ignore' }).unref(); } catch (e) { return { on: false, error: String(e) }; }
return { on: true };
}
@@ -197,30 +216,38 @@ async function getTickets() {
// buffer — the default 1MB silently truncated the response and every count parsed as 0.
const { stdout: boardData } = await run('curl', ['-s', '-m', '4', '-u', 'admin:DW2024!',
'http://127.0.0.1:9794/api/tickets?status=doing,blocked,open,stopped'], 6000, 8 << 20);
- let tickets = { blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
- try {
- const j = JSON.parse(boardData || '[]');
- if (Array.isArray(j)) {
- let newest = null;
- for (const row of j) {
- const status = row.status || '';
- // Peel-out (mirrors the board / old TicketBar): a ticket with parked=true in an active
- // status counts as PARKED and is removed from its status bucket. "idle" = stopped tickets.
- const isParked = row.parked && ['open', 'blocked', 'doing'].includes(status);
- if (isParked) tickets.parked++;
- else if (status === 'blocked') tickets.blocked++;
- else if (status === 'open') tickets.open++;
- else if (status === 'doing') tickets.doing++;
- else if (status === 'stopped') tickets.idle++;
- const ts = Date.parse(row.updated_at || row.status_since || '') || 0;
- if (!newest || ts > newest.ts) newest = { ts, label: row.title ? `${row.id}: ${row.title}` : row.id };
- }
- if (newest) tickets.latest = newest.label;
+ // KEEP-LAST-GOOD (mirrors getDots): curl returns '' on connection-refused/timeout (board down),
+ // and '' -> JSON.parse('[]') parses CLEANLY as [] — only a *parse* error hit the old catch, so a
+ // dead board silently zeroed every count. Treat an empty/whitespace OR unparseable body as a
+ // FAILED fetch: return null so refreshTickets keeps the last-good snapshot instead of flipping the
+ // bar's ticket summary to all-zeros. A genuine empty board returns the literal '[]' (non-empty).
+ if (!boardData || !boardData.trim()) return null;
+ let j;
+ try { j = JSON.parse(boardData); } catch (e) { return null; }
+ const tickets = { blocked: 0, open: 0, idle: 0, doing: 0, parked: 0, latest: null };
+ if (Array.isArray(j)) {
+ let newest = null;
+ for (const row of j) {
+ const status = row.status || '';
+ // Peel-out (mirrors the board / old TicketBar): a ticket with parked=true in an active
+ // status counts as PARKED and is removed from its status bucket. "idle" = stopped tickets.
+ const isParked = row.parked && ['open', 'blocked', 'doing'].includes(status);
+ if (isParked) tickets.parked++;
+ else if (status === 'blocked') tickets.blocked++;
+ else if (status === 'open') tickets.open++;
+ else if (status === 'doing') tickets.doing++;
+ else if (status === 'stopped') tickets.idle++;
+ const ts = Date.parse(row.updated_at || row.status_since || '') || 0;
+ if (!newest || ts > newest.ts) newest = { ts, label: row.title ? `${row.id}: ${row.title}` : row.id };
}
- } catch (e) { /* keep last */ }
+ if (newest) tickets.latest = newest.label;
+ }
return { updated: Date.now(), ...tickets };
}
-async function refreshTickets() { try { ticketsSnapshot = await getTickets(); } catch (e) { /* keep last */ } }
+// Keep the last-good snapshot when getTickets signals a failed fetch (null), mirroring getDots.
+async function refreshTickets() {
+ try { const t = await getTickets(); if (t) ticketsSnapshot = t; } catch (e) { /* keep last */ }
+}
async function refresh() {
if (refreshing) return;
refreshing = true;
← 36f503b dotbar: fix ticket-segment clicks — Electron can't window.op
·
back to Desktop Dotbar
·
auto-data-snapshot: 2026-09-23T11:06:44 (1 data files) — sta 08b744a →