fix(runtime): never block the event loop on filesystem I/O (REPL-28760)
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/sizeget truly async bodies (signatures unchanged; all 32 callers alreadyawait). SyncstatrenamedstatSync, confined to its 3 local-path CLI callers.FSUtil.Service.resolvePath— async twin of syncresolve()(realpath, resolved-as-given fallback). Used for edit lock keys and grep search roots, replacingrealpathSyncon those hot paths. Edit locks now use the sharedKeyedMutex(also fixes unbounded lock-map growth).util/stall-watchdog.ts— 250ms drift probe: ifEffect.sleepwakes >1s late the loop was starved; logsevent-loop.stallwith in-flight tool/LSP breadcrumbs (session/tools.ts,lsp.touchFile). Forked for the server's lifetime inServer.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.resolvecallers inproject/project.ts+project/instance-store.ts(instance-directory resolution: local-by-construction, and the resolved path is a cache key inside concurrency-sensitiveuninterruptibleMasklogic) — follow-up task spawned.- files-fuse kernel caching + bedrock-api
childrencost (andytown, separate PRs) — this PR makes slow mounts non-fatal; those make them fast.
Testing
bun test: filesystem (incl. newresolvePathsymlink-alias tests), stall-watchdog (real loop-starvation detection), edit/grep/read suites — 102 pass.tsgo --noEmitclean inpackages/coreandpackages/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.tsexists/isDir/sizetruly async viafs/promises; renamed syncstattostatSyncand limited it to known‑local CLI probes. - Started a watchdog that uses a monotonic clock (
performance.now) and logsevent-loop.stallwhen timers wake >1s late; wired intoServer.listen. - Instrumented tool calls and LSP root walks with breadcrumbs (unique ids) so stall logs name the in‑flight operation.
- Made
-
Refactors
- Added
FSUtil.Service.resolvePath(async realpath; fallback only on NotFound) and used it for edit lock keys and grep roots, replacingrealpathSyncon hot paths. - Switched edit locks to shared
KeyedMutexand canonicalized lock keys viaresolvePathto avoid aliasing and unbounded map growth.
- Added