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