[object Object]

← back to Ticket System

fix: async /api/skills scan + stale-while-revalidate to prevent 502ms event-loop block (TK-11499)

1e2150794436a938d622ee174fddca672faaafaa · 2026-09-12 04:05:22 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 1e2150794436a938d622ee174fddca672faaafaa
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sat Sep 12 04:05:22 2026 -0700

    fix: async /api/skills scan + stale-while-revalidate to prevent 502ms event-loop block (TK-11499)
---
 server.js | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 53 insertions(+), 10 deletions(-)

diff --git a/server.js b/server.js
index f21636fb..2926f5f6 100644
--- a/server.js
+++ b/server.js
@@ -164,20 +164,61 @@ function summarize(t) {
   return out;
 }
 
-// ── /api/skills cache (sibling TK-11499) ──
-// Not keyed off events.jsonl — it walks the skill roots and reads every SKILL.md (measured
-// 1.255s here, 502ms worst elsewhere). It shares this one main thread, so leaving it
-// synchronous per-request would keep /healthz stallable and defeat the fix above.
+// ── /api/skills cache (TK-11499) ──
+// Walks 583+22 skill dirs with sync I/O per request → measured 58ms mean / 502ms worst,
+// blocking the main event loop. Fix: async scan + stale-while-revalidate so the loop is
+// NEVER held: a stale cache entry is returned immediately and a background refresh fires.
 const SKILLS_TTL_MS = 60000;
-let skillsCache = { at: 0, entry: null };
+let skillsCache = { at: 0, entry: null, refreshing: false };
+
+// Async scan — uses fs.promises so it never blocks the event loop.
+async function installedSkillsAsync() {
+  const fsp = fs.promises;
+  const found = new Map();
+  for (const root of SKILL_ROOTS) {
+    let names = [];
+    try { names = await fsp.readdir(root); } catch { continue; }
+    await Promise.all(names.map(async dir => {
+      const file = path.join(root, dir, 'SKILL.md');
+      let body, st;
+      try { [body, st] = await Promise.all([fsp.readFile(file, 'utf8'), fsp.stat(file)]); } catch { return; }
+      const fm = body.match(/^---\s*\n([\s\S]*?)\n---/);
+      const meta = fm ? fm[1] : '';
+      const name = (meta.match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1] || dir;
+      const rawDesc = (meta.match(/^description:\s*[>|-]?\s*["']?(.+?)["']?\s*$/m) || [])[1] || '';
+      const key = String(name).trim().toLowerCase();
+      if (!found.has(key)) found.set(key, {
+        name: String(name).trim(), slug: dir, description: String(rawDesc).trim(),
+        root: root.includes('.agents') ? 'agents' : 'codex', path: file,
+        created_at: (st.birthtime || st.mtime).toISOString(), updated_at: st.mtime.toISOString(),
+      });
+    }));
+  }
+  return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+// Background refresh — updates the cache without holding the event loop.
+function refreshSkillsCache() {
+  if (skillsCache.refreshing) return;
+  skillsCache.refreshing = true;
+  installedSkillsAsync().then(skills => {
+    const raw = Buffer.from(JSON.stringify(skills));
+    const entry = { raw };
+    zlib.gzip(raw, (err, gz) => { if (!err) entry.gz = gz; });
+    skillsCache = { at: Date.now(), entry, refreshing: false };
+  }).catch(() => { skillsCache.refreshing = false; });
+}
+
+// Stale-while-revalidate: always returns immediately; refreshes async when stale.
 function cachedSkillsPayload() {
-  if (skillsCache.entry && Date.now() - skillsCache.at < SKILLS_TTL_MS) return skillsCache.entry;
-  const entry = { raw: Buffer.from(JSON.stringify(installedSkills())) };
-  zlib.gzip(entry.raw, (err, gz) => { if (!err) entry.gz = gz; });
-  skillsCache = { at: Date.now(), entry };
-  return entry;
+  const stale = !skillsCache.entry || Date.now() - skillsCache.at >= SKILLS_TTL_MS;
+  if (stale) refreshSkillsCache();
+  return skillsCache.entry || { raw: Buffer.from('[]') }; // empty sentinel on cold start
 }
 
+// Pre-warm at startup so the first real request hits the cache.
+refreshSkillsCache();
+
 const OFFICE_HTML = path.join(__dirname, 'office.html');
 const BOARD_HTML = path.join(__dirname, 'board.html');
 const SKILL_ROOTS = [
@@ -185,6 +226,8 @@ const SKILL_ROOTS = [
   path.join(os.homedir(), '.codex', 'skills'),
 ];
 
+// Kept for any callers that still reference it synchronously (none in current server.js,
+// but preserved to avoid breaking imports from external scripts).
 function installedSkills() {
   const found = new Map();
   for (const root of SKILL_ROOTS) {

← 73d9bb51 ticket bar: show swap / qwen-27b sessions / ollama reachabil  ·  back to Ticket System  ·  fix: handle ENOENT gracefully in lock-release path to preven a8ca813c →