[object Object]

← 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 →