Skip to content

feat(fs): compiled fs/promises real thread-pool backgrounding (#971)#1010

Merged
nickna merged 1 commit into
mainfrom
wrk/issue-971-real-async
Jun 29, 2026
Merged

feat(fs): compiled fs/promises real thread-pool backgrounding (#971)#1010
nickna merged 1 commit into
mainfrom
wrk/issue-971-real-async

Conversation

@nickna

@nickna nickna commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Closes #971 — the last open child of epic #968.

Problem

Compiled fs/promises was fake-async: each op ran its sync implementation inline and wrapped the result in Task.FromResult (already-completed). The I/O ran on the calling thread with no overlap, diverging from the interpreter's real Task.Run.

Fix

A shared $FsAsyncOp closure + FsRunAsync helper give genuine backgrounding: EventLoop.Ref() → run the sync op on the thread pool (Task.RunMethodInfo.Invoke) → Unref on completion, so the loop stays alive until the op and its callback/await continuation drain. Mirrors the proven fetch/DNS/timers pattern and the interpreter's refsEventLoopWhileInFlight. All ~21 fs/promises ops now build their args and call FsRunAsync (rm's inline logic moved to FsRmAsyncImpl). The old Begin/EndFsAsyncTryCatch helpers are gone.

Error mapping preserved: $FsAsyncOp.Worker unwraps TargetInvocationException so a sync NodeError faults the Task with the same err.code.

The subtle part — event-loop liveness

The facade derives callbacks from the promise via a separate .then continuation registered after ours, so dropping the loop ref immediately let a fire-and-forget callback (fs.readFile(path, cb), no awaiter) race program exit under load. The Unref is therefore held a short grace period (Task.Delay) before being scheduled onto the loop; the callback posts within microseconds of I/O completion, so it always drains first. Awaited ops are unaffected (their pending top-level task keeps the loop alive regardless).

Verification

New dual-mode test asserts result + err.code parity, loop liveness (a real fs op concurrent with a timer both complete), and Promise.all concurrency. Byte-identical interp == compiled; standalone preserved. Full suite 14482/0 run twice (non-flaky); TS conformance unchanged. Merging this completes epic #968.

Compiled fs/promises was fake-async: each op ran its sync implementation inline
and wrapped the result in Task.FromResult (already-completed), so the I/O ran on
the calling thread with no overlap, diverging from the interpreter's real Task.Run.

Replace it with genuine backgrounding. A shared $FsAsyncOp closure + FsRunAsync
helper now: Ref the $EventLoop, run the sync op on the thread pool
(Task.Run -> MethodInfo.Invoke), and Unref on completion — so the loop stays
alive until the op (and its callback/await continuation) drains. This mirrors the
proven fetch/DNS/timers pattern and the interpreter's refsEventLoopWhileInFlight.
All ~21 fs/promises ops now build their args and call FsRunAsync (rm's inline
logic moved to FsRmAsyncImpl).

Error mapping preserved: $FsAsyncOp.Worker unwraps TargetInvocationException so a
sync NodeError faults the Task with the same err.code.

Event-loop liveness: the Unref is held for a short grace period (Task.Delay) before
being scheduled onto the loop. The facade derives callbacks from the promise via a
SEPARATE `.then` continuation registered after ours; dropping the ref immediately
let a fire-and-forget callback (fs.readFile(path, cb), no awaiter) race program
exit under load. The callback posts within microseconds of I/O completion, so the
grace guarantees it drains before the loop goes quiescent. Awaited ops are
unaffected (their pending top-level task already keeps the loop alive).

Byte-identical interp==compiled (result + err.code + liveness + concurrency),
standalone preserved. New dual-mode test; full suite 14482/0 (x2, non-flaky);
TS conformance unchanged.
@nickna nickna merged commit cf6d55e into main Jun 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs: compiled fs/promises — real thread-pool backgrounding

1 participant