UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

138 lines (137 loc) • 6.03 kB
/** * Slow-filesystem detection (#462). * * On WSL 9p mounts (`/mnt/c/...`), synchronous file-tree walks freeze the pi * TUI: each `stat`/`readdir` costs ~1.3ms over 9p vs ~17µs native (measured * anchor on a Windows host running WSL2 — a 75x slowdown). A 5,000-file sync * walk on 9p costs ~6.5s of stat time alone, all on the event loop. * * This module classifies the workspace by MEASUREMENT, not path shape (no * `/mnt/` string-sniffing — that misses drvfs/NFS/SMB and false-positives on * fast 9p configurations). A cheap latency probe times a handful of * `fs.statSync` calls under the project root at session start; if the median * exceeds a threshold, the workspace is flagged "slow FS" for the rest of the * process, and callers route sync tree walks to reduced caps / async * collectors instead. * * Escape hatches: * - `PI_LENS_ALLOW_SLOW_FS_SCAN=1` — disable slow-FS mode entirely (full * normal behavior even when the probe would flag the FS as slow). * - `PI_LENS_FORCE_SLOW_FS=1` — force slow-FS mode on regardless of the * probe (testing/CI, or a user who knows their FS is slow but the probe * under-fires). * - `PI_LENS_SLOW_FS_THRESHOLD_US` — override the median-microseconds * threshold (default 500). * * Follows the lazy-memoized-config house style (see `runtime-config.ts`): env * values are read lazily at call time, not module load, and `Number(...)` is * gated through `Number.isFinite` before use. */ import * as fs from "node:fs"; import * as path from "node:path"; import { toPositiveFinite } from "./env-utils.js"; import { normalizeFilePath } from "./path-utils.js"; /** Default median-stat threshold (microseconds) above which a workspace is * classified as slow FS. Measured anchor: 9p ≈ 1300µs/stat, native NTFS/ext4 * < 200µs/stat — 500µs sits well between the two. */ export const DEFAULT_SLOW_FS_THRESHOLD_US = 500; /** Cap on how many entries the probe stats — keeps the probe itself cheap * (15 stats x ~1.3ms worst case is still well under 50ms). */ const PROBE_SAMPLE_CAP = 15; function resolveThresholdMicros() { const envValue = toPositiveFinite(process.env.PI_LENS_SLOW_FS_THRESHOLD_US); return envValue > 0 ? envValue : DEFAULT_SLOW_FS_THRESHOLD_US; } function median(values) { if (values.length === 0) return 0; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; } /** * Time up to `PROBE_SAMPLE_CAP` `fs.statSync` calls on entries directly under * `rootDir` and return the median per-stat cost in microseconds. Wrapped * entirely in try/catch: any failure (missing dir, permission error, empty * dir) yields `slow: false` — a probe failure must never itself degrade * behavior. */ export function probeSlowFs(rootDir) { try { const resolvedRoot = path.resolve(rootDir); const entries = fs.readdirSync(resolvedRoot).slice(0, PROBE_SAMPLE_CAP); if (entries.length === 0) { return { slow: false, medianStatMicros: 0, samples: 0 }; } const samplesMicros = []; for (const entry of entries) { const fullPath = path.join(resolvedRoot, entry); const startedAt = process.hrtime.bigint(); try { fs.statSync(fullPath); } catch { continue; // vanished / permission-denied entry — skip, don't count } const elapsedNs = process.hrtime.bigint() - startedAt; samplesMicros.push(Number(elapsedNs) / 1000); } if (samplesMicros.length === 0) { return { slow: false, medianStatMicros: 0, samples: 0 }; } const medianStatMicros = median(samplesMicros); const threshold = resolveThresholdMicros(); return { slow: medianStatMicros > threshold, medianStatMicros, samples: samplesMicros.length, }; } catch { return { slow: false, medianStatMicros: 0, samples: 0 }; } } /** Process-lifetime memo of the slow-FS verdict, keyed by normalized cwd so * `/` vs `\` inputs share one entry (see path-key invariant tests). */ const slowFsVerdictCache = new Map(); /** * Resolve (and memoize) the slow-FS verdict for `cwd`. Resolution order: * 1. `PI_LENS_ALLOW_SLOW_FS_SCAN=1` — kill switch, always false. * 2. `PI_LENS_FORCE_SLOW_FS=1` — always true, probe skipped entirely. * 3. Measured probe (memoized per cwd for the process lifetime). */ export function getSlowFsVerdict(cwd) { if (process.env.PI_LENS_ALLOW_SLOW_FS_SCAN === "1") { return { slow: false, medianStatMicros: 0, samples: 0 }; } if (process.env.PI_LENS_FORCE_SLOW_FS === "1") { return { slow: true, medianStatMicros: 0, samples: 0 }; } const key = normalizeFilePath(path.resolve(cwd)); const cached = slowFsVerdictCache.get(key); if (cached) return cached; const result = probeSlowFs(cwd); slowFsVerdictCache.set(key, result); return result; } /** Convenience predicate for call sites that only need the boolean verdict. */ export function isSlowFs(cwd) { return getSlowFsVerdict(cwd).slow; } /** Test-only: clear the memoized verdict cache so a subsequent call re-probes * (or re-reads the env kill switches). */ export function _resetSlowFsForTests() { slowFsVerdictCache.clear(); } /** Reduced sync `maxFiles` cap applied in slow-FS mode (env override is * deliberately NOT supported per #462's design — this is a safety clamp, not * a tuning knob). */ export const SLOW_FS_REDUCED_MAX_FILES = 500; /** Human-readable degradation notice surfaced once per session when slow-FS * mode engages, so a user never sees a silently-empty scan result. */ export function slowFsDegradationNotice() { return "slow filesystem detected (set PI_LENS_ALLOW_SLOW_FS_SCAN=1 to override)"; }