← back to Ticket System
fix: stop /api/tickets from blocking the event loop on a sync child-process spawn
2ba161d8072accc92d6dd5524e0291114bfbbfd1 · 2026-09-25 14:26:10 -0700 · Steve Abrams
parkedTicketIds() called execFileSync() to run parked.mjs directly in the
/api/tickets and /kanban hot GET path, re-run whenever its 5s cache went
stale. Forking a fresh node interpreter is normally <200ms, but this box
runs 150+ always-on node server.js processes (load avg 13-18 measured);
under that load a fork+exec stretched to many seconds, and because
execFileSync blocks the single-threaded event loop, every other in-flight
request (including /healthz) queued behind it too -- explaining the
observed /api/tickets timeouts and >120s hangs (TK-12286).
Converted to the same stale-while-revalidate shape already used by
cachedSkillsPayload: execFile (async) refreshes a background cache: a
request never blocks on the subprocess, it gets the last known parked set
immediately. Pre-warmed at startup. Verified with a temp instance on
:19794 (killed after) -- /healthz stayed fast (~0.65s, was previously
stalling >8-120s) concurrent with a cold /api/tickets fold; full lib.test.js
suite (32/32) still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
Files touched
Diff
commit 2ba161d8072accc92d6dd5524e0291114bfbbfd1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 14:26:10 2026 -0700
fix: stop /api/tickets from blocking the event loop on a sync child-process spawn
parkedTicketIds() called execFileSync() to run parked.mjs directly in the
/api/tickets and /kanban hot GET path, re-run whenever its 5s cache went
stale. Forking a fresh node interpreter is normally <200ms, but this box
runs 150+ always-on node server.js processes (load avg 13-18 measured);
under that load a fork+exec stretched to many seconds, and because
execFileSync blocks the single-threaded event loop, every other in-flight
request (including /healthz) queued behind it too -- explaining the
observed /api/tickets timeouts and >120s hangs (TK-12286).
Converted to the same stale-while-revalidate shape already used by
cachedSkillsPayload: execFile (async) refreshes a background cache: a
request never blocks on the subprocess, it gets the last known parked set
immediately. Pre-warmed at startup. Verified with a temp instance on
:19794 (killed after) -- /healthz stayed fast (~0.65s, was previously
stalling >8-120s) concurrent with a cold /api/tickets fold; full lib.test.js
suite (32/32) still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D7LB6rCwpA7ubJTqYzqXEX
---
server.js | 34 +++++++++++++++++++++++++---------
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/server.js b/server.js
index 68af53da..dba09fbf 100644
--- a/server.js
+++ b/server.js
@@ -439,17 +439,33 @@ const PARKED_MJS = path.join(os.homedir(), '.claude', 'skills', 'parked', 'parke
// Tickets are stored with FULL ids (TK-11946-build-...); the registry stores the
// SHORT id (TK-11946). Compare on the short form so a parked ticket matches.
const shortTk = id => (String(id).match(/^(TK-\d+)/) || [id, id])[1];
-let _parkedCache = { at: 0, ids: new Set() };
+// TK-12286: this used to be execFileSync — a SYNCHRONOUS child-process spawn sitting
+// directly in the /api/tickets and /kanban hot GET path, re-run every 5s. Forking a
+// fresh node interpreter normally costs <200ms, but under the fleet's actual load
+// (dozens of concurrent yoloforever agents, load avg 14-20) a fork+exec can stretch to
+// many seconds — and because execFileSync blocks the ENTIRE single-threaded event loop,
+// every other in-flight request (including the 3-line /healthz) queued behind it too,
+// which is exactly the /api/tickets-timeout-hangs-everything symptom this fixes.
+// Same stale-while-revalidate shape as cachedSkillsPayload above: a request NEVER blocks
+// on the subprocess — it gets the last known set immediately and a refresh fires in the
+// background via the async execFile.
+let _parkedCache = { at: 0, ids: new Set(), refreshing: false };
+function refreshParkedCache() {
+ if (_parkedCache.refreshing) return;
+ _parkedCache.refreshing = true;
+ execFile(process.execPath, [PARKED_MJS, 'list-parked', '--json'], { encoding: 'utf8', timeout: 8000 }, (err, out) => {
+ if (!err) {
+ try { _parkedCache.ids = new Set(JSON.parse(out).filter(e => e && e.kind === 'ticket').map(e => e.id)); } catch {}
+ } // registry unreadable/timed out -> keep the last known set (never hide a ticket on a blind read)
+ _parkedCache.at = Date.now();
+ _parkedCache.refreshing = false;
+ });
+}
function parkedTicketIds() {
- if (Date.now() - _parkedCache.at < 5000) return _parkedCache.ids;
- let ids = new Set();
- try {
- const out = execFileSync(process.execPath, [PARKED_MJS, 'list-parked', '--json'], { encoding: 'utf8', timeout: 8000 });
- ids = new Set(JSON.parse(out).filter(e => e && e.kind === 'ticket').map(e => e.id));
- } catch { /* registry unreadable -> treat none parked (never hide a ticket on a blind read) */ }
- _parkedCache = { at: Date.now(), ids };
- return ids;
+ if (Date.now() - _parkedCache.at >= 5000) refreshParkedCache();
+ return _parkedCache.ids;
}
+refreshParkedCache(); // pre-warm at startup so the first request isn't served an empty set
// Attach ranking/ratings to a flat ticket list. Only open/doing/blocked get ranked
// (done/stopped are excluded from the ranking per the brief) — those get priority 0 / no rank.
← d4f10cf6 auto-data-snapshot: 2026-09-25T11:52:49 (1 data files) — TK-
·
back to Ticket System
·
auto-data-snapshot: 2026-09-25T14:28:14 (2 data files) — dat 39f8e674 →