fix(runtime): never block the event loop on filesystem I/O (REPL-28760)

Merged
PI
4mo ago
+140-36

Why

REPL-28760: agent chat wedged for 2-3 minutes ("Failed to send message"). The opencode process froze in three back-to-back 40-55s stalls — zero log output, missed SSE heartbeats, HTTP server unresponsive — each ending exactly when a read against the files-fuse mount completed.

Root cause: sync filesystem syscalls behind async signatures. Filesystem.exists() was literally return existsSync(p); LSP root detection (NearestRoot → Filesystem.up) probes marker files with it on every read tool call, walking up from the file's directory. On the FUSE mount each probe is a multi-second syscall on the main thread, and consecutive probes chain through the microtask queue — so the loop gets zero ticks for the whole walk. session.create then times out → user-facing toast.

What

  • util/filesystem.ts — exists/isDir/size get truly async bodies (signatures unchanged; all 32 callers already await). Sync stat renamed statSync, confined to its 3 local-path CLI callers.
  • FSUtil.Service.resolvePath — async twin of sync resolve() (realpath, resolved-as-given fallback). Used for edit lock keys and grep search roots, replacing realpathSync on those hot paths. Edit locks now use the shared KeyedMutex (also fixes unbounded lock-map growth).
  • util/stall-watchdog.ts — 250ms drift probe: if Effect.sleep wakes >1s late the loop was starved; logs event-loop.stall with in-flight tool/LSP breadcrumbs (session/tools.ts, lsp.touchFile). Forked for the server's lifetime in Server.listen. Any regression of this class now names itself in Loki instead of costing a multi-hour investigation.

Empirical basis: on Bun 1.3.14, async node:fs ops keep 10ms timers ticking during a kernel-blocked open (28ms max gap), while a chain of 20 sync-in-async calls starves the loop for their entire combined duration (2000ms gap, zero timer fires). The watchdog test exercises the real detector (stall_ms: 1202 in CI output).

Deliberately out of scope

  • FSUtil.resolve callers in project/project.ts + project/instance-store.ts (instance-directory resolution: local-by-construction, and the resolved path is a cache key inside concurrency-sensitive uninterruptibleMask logic) — follow-up task spawned.
  • files-fuse kernel caching + bedrock-api children cost (andytown, separate PRs) — this PR makes slow mounts non-fatal; those make them fast.

Testing

  • bun test: filesystem (incl. new resolvePath symlink-alias tests), stall-watchdog (real loop-starvation detection), edit/grep/read suites — 102 pass.
  • tsgo --noEmit clean in packages/core and packages/opencode.

🤖 Generated with Claude Code


Summary by cubic

Make filesystem I/O truly async and add an event‑loop stall watchdog so slow mounts can’t wedge the server. Fixes REPL-28760 by removing sync fs calls behind async signatures that froze agent chat.

  • Bug Fixes

    • Made util/filesystem.ts exists/isDir/size truly async via fs/promises; renamed sync stat to statSync and limited it to known‑local CLI probes.
    • Started a watchdog that uses a monotonic clock (performance.now) and logs event-loop.stall when timers wake >1s late; wired into Server.listen.
    • Instrumented tool calls and LSP root walks with breadcrumbs (unique ids) so stall logs name the in‑flight operation.
  • Refactors

    • Added FSUtil.Service.resolvePath (async realpath; fallback only on NotFound) and used it for edit lock keys and grep roots, replacing realpathSync on hot paths.
    • Switched edit locks to shared KeyedMutex and canonicalized lock keys via resolvePath to avoid aliasing and unbounded map growth.