File size: 3,397 Bytes
93039d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
// Warn when a synchronous fs call blocks the event loop, and name the caller.
//
// On the Space, DATA_DIR / HOME / CLAUDE_CONFIG_DIR are on the FUSE bucket,
// where a statSync costs ~85ms against 0.01ms on local disk. Node runs JS on one
// thread, so one such call freezes every pane the server is carrying for that
// long. This has bitten us three times now — claudeTranscriptsSince, then
// codexRolloutsSince — and it is hard to spot because **the kernel's pressure
// accounting cannot see it**: FUSE parks the caller in wait_event_interruptible
// (state S), not io_schedule, so the wait is never counted as iowait and
// /proc/pressure/io reads 0.00 while the loop is stuck. Hence a tripwire here.
//
// Silent on a normal filesystem. AM_SLOWFS_MS=0 disables it.
import fs from 'node:fs';

const THRESHOLD_MS = Number(process.env.AM_SLOWFS_MS ?? 50);
// One line per call site per window: a bucket stall must not become a log storm.
const REPEAT_MS = 10_000;

const lastLogged = new Map(); // call site -> { at, suppressed }

// The first frame outside this file that names real source. Internal callback
// frames ("at Array.forEach (<anonymous>)") point at nothing actionable.
function callSite(skip) {
  const holder = {};
  try { Error.captureStackTrace(holder, skip); } catch { return '?'; }
  for (const raw of String(holder.stack || '').split('\n').slice(1)) {
    const line = raw.trim();
    if (!line.startsWith('at ') || line.includes('slowfs.js') || line.includes('node:')) continue;
    if (!line.includes('file:') && !line.includes('/')) continue;
    return line.slice(3);
  }
  return '?';
}

export function installSlowFsProbe({ thresholdMs = THRESHOLD_MS } = {}) {
  if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) return false;

  // Every *Sync method, rather than a hand-kept list that drifts as callers move.
  for (const name of Object.keys(fs).filter((k) => k.endsWith('Sync'))) {
    const original = fs[name];
    if (typeof original !== 'function') continue;

    function slowFsWrapper(...args) {
      const t0 = performance.now();
      try {
        return original.apply(this, args);
      } finally {
        // This sits in front of every sync fs call in the process, including
        // ones inside catch blocks: it must never throw and never swallow.
        try {
          const ms = performance.now() - t0;
          if (ms >= thresholdMs) warn(name, args[0], ms, slowFsWrapper);
        } catch { /* diagnostics must not break the app */ }
      }
    }
    // realpathSync.native and friends hang off the function itself.
    Object.assign(slowFsWrapper, original);
    Object.defineProperty(slowFsWrapper, 'name', { value: name });
    fs[name] = slowFsWrapper;
  }
  console.warn(`[slowfs] watching synchronous fs; logging calls over ${thresholdMs}ms`);
  return true;
}

function warn(method, target, ms, skip) {
  const site = callSite(skip);
  const key = `${method} ${site}`;
  const prev = lastLogged.get(key);
  const now = Date.now();
  if (prev && now - prev.at < REPEAT_MS) { prev.suppressed++; return; }
  const extra = prev?.suppressed ? ` (+${prev.suppressed} more since last line)` : '';
  lastLogged.set(key, { at: now, suppressed: 0 });
  // target may be a Buffer, URL or fd; String() covers all three well enough.
  console.warn(`[slowfs] ${ms.toFixed(0)}ms ${method} ${String(target)} — at ${site}${extra}`);
}