feat: a host-callable job checkpoint, and async in library mode - #288
Open
mmamedel wants to merge 1 commit into
Open
feat: a host-callable job checkpoint, and async in library mode#288mmamedel wants to merge 1 commit into
mmamedel wants to merge 1 commit into
Conversation
Embedders that own the main thread cannot let user code write `async` handlers today. While the program is parked inside an outbound FFI call scriptc's loop is not running, so a promise continuation queued during a re-entry stays queued until the program's main body returns — for an embedded program, at exit. Library mode refuses `async` outright. Adds one primitive, `scr_drain_jobs()`: run the pending process.nextTick and promise-job queues to joint exhaustion, then return. It is the loop's own two checkpoint stations, factored out so there is one implementation rather than two that drift. It is deliberately NOT a loop turn — no clock is read, no timer fires, no descriptor is polled, no thread is created, no signal handler is installed, and no unhandled-rejection verdict is reached. A host may drain with a fiber suspended beneath the native call it is parked in, so the drain runs as the program's main context and restores the suspended frame's current-fiber, exception cell, and ALS context before returning — the restore scr_async_spawn already does. Executables expose it to their host as `scriptc_drain()`; library artifacts expose it as `<prefix>_drain` when the profile declares `abi.drain_symbol`. Library mode then admits `async` functions, `await`, promise values and `queueMicrotask`. SC4005 keeps refusing everything a loop TURN would have to service, plus the two async shapes with no host-drain story: a top-level `await` (library init is module evaluation, and nothing at the ABI boundary can wait for it) and generators (they suspend for a consumer, not a queue). fs.promises stays refused — its handles reach units outside the library link set. wasm32-wasi's C backend asks a DIFFERENT question — "can this graph run without a resumable native stack?" — and had been reusing the library gate's answer. It gets its own detector (moduleCoroutineSurface) now that the two have parted ways. The v1 contract is unchanged, and now literal at the symbol level: the promise/fiber unit joins a library link only when the graph needs it, and its SCR_LIB flavor fences out the loop, the timer heap and its whole surface, the check phase, every loop hook, the timers/promises and fs.promises surfaces, and the unhandled-rejection verdict. `nm` over an async library archive shows promises, fibers and the job queues — no `scr_loop_run`, no `scr_set_timeout`, no poll, no child hook, and the K8 ambient audit (no sigaction/signal/pthread_create/atexit) is untouched. The same link gate closes a latent bug: `process.nextTick` was already admitted by SC4005, but `scr_next_tick` lives in the unit no library archive linked, so such a graph produced an unlinkable archive with a dangling reference no prefix-scoped symbol check could see. The contract sidecar's `async_free` becomes a computed fact of the graph instead of a structural assertion. Tests: tests/harness/ffi-drain.test.ts pins the executable lane against a native host that owns the thread — a continuation stays queued across a second re-entry and runs at the drain, a promise the host settles from its own callback queues rather than resumes, and a drain with an async frame suspended beneath it leaves that frame able to await again. tests/harness/library-mode.test.ts K15 pins the same story through `<prefix>_drain` plus the archive's symbol audit. All fail on a no-op drain. Closes vercel-labs#265 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LnudUDvSPwJQPLyHmrEv5M
Contributor
|
Someone is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
| } | ||
| } | ||
| if (caller != NULL) { | ||
| scr_current = caller; |
Contributor
There was a problem hiding this comment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #265.
Embedders cannot offer
asynchandlers today. While a program is parked inside an outbound FFI call, scriptc's loop is not running — so a promise continuation queued during one re-entry is still queued at the next one, and only runs when the program's main body returns, which for an embedded program means "at exit". Library mode refusesasyncoutright.This adds the one thing that was missing: a point at which a host that owns the thread can say "now run what is ready".
The primitive
scr_drain_jobs()runs the pendingprocess.nextTickand promise-job queues to joint exhaustion and returns. It is not a new scheduler — it is the loop's own two checkpoint stations, factored out ofscr_loop_runso there is one implementation rather than two that drift.It is deliberately not a loop turn. No clock is read, no timer fires, no descriptor is polled, no thread is created, no signal handler is installed, and no unhandled-rejection verdict is reached (that decision belongs to a complete turn — the loop's, in the executable lane).
Two spellings:
Executable lane —
scriptc_drain(), the one runtime symbol a native host linked into a scriptc executable is invited to call:Library mode —
abi.drain_symbolin the profile emits<prefix>_drainbeside<prefix>_reset, with the same entry prologue and escaped-exception check every other library entry has.A host may drain with a fiber on the stack beneath the native call it is parked in — the outbound call was made from inside an
asyncfunction. The drain therefore runs deliberately as the program's main context and restores the suspended frame's current-fiber, exception cell, andAsyncLocalStoragecontext before returning, the same restorescr_async_spawnalready does around an eager spawn. Without it the suspended frame's nextawaitparks nothing and the program dies onawait outside an async function; that is pinned by its own test.It returns
falsewhen a continuation threw. The cell stays pending, so the throw resumes through the ordinary unwind when the outer native call returns — a host never unwinds script frames itself. A drain entered while a throw is already in flight runs nothing and returnsfalse, matching how a script-thread FFI callback is already suppressed in that situation.Library mode admits async
SC4005now refuses only the event-loop and ambient-process surface a loop turn would have to service.asyncfunctions,await, promise values andqueueMicrotaskare admitted — a continuation is queued work, and the host runs the queue.Two async shapes stay refused, because neither has a host-drain story:
await— library init is module evaluation, and nothing at the ABI boundary can wait for it; a half-evaluated graph would look initialized;fs.promisesalso stays refused: its promises settle eagerly, but its handle surface reaches units outside the library link set. Refused as one family rather than admitted piecewise.queueMicrotaskwas only ever refused by namespace accident — itsIrLibFnspelling lives undertimers., and it is not a timer at all: it pushes the same FIFO a promise continuation lands on. It is admitted explicitly rather than by prefix.The sidecar's
async_freestops being a structural assertion and becomes a computed fact of the graph, besidedeterministic.A latent link bug this also closes
process.nextTickwas already admitted by the gate — no refusal prefix covers it and no coarse predicate sees it — butscr_next_ticklives inscr_async.c, which no library archive linked. Onmaintoday:The archive builds and is unlinkable. K1's "no prefix-carrying undefineds" cannot see it because the symbol carries no profile prefix. The same
moduleUsesAsyncgate that links the unit for promises links it forprocess.nextTickandqueueMicrotasktoo, so the archive now defines what it references — and the host's drain is what finally runs those ticks.The v1 contract is preserved — and now literal
Unchanged, and stronger than before at the symbol level. The promise/fiber unit (
scr_async.c) joins a library link only when the graph reaches a continuation or the profile declares the drain entry, and its-DSCR_LIBflavor fences out:scr_loop_runitself;setTimeout/setInterval/ref/unref/refreshsurface, and the loop's monotonic clock;setImmediate,clearImmediate) andclearInterval;scr_loop_set_io/events/net/dgram/watch/ffi/stream, the island deadline) andscr_loop_has_ready;fs.promisesandtimers/promises;process.getActiveResourcesInfo's census over the loop's own bookkeeping;nmover an async library archive shows promises, fibers, generators and the two job queues — and noscr_loop_run, noscr_set_timeout, nopoll, no child-process hook. The K8 mechanical ambient audit (no undefined reference tosigaction,signal,pthread_create,atexit,setvbuf) is untouched, and K15 re-applies K8'sloopishcheck to the archive that does carry the promise unit.Session teardown is the other new obligation: a library has no
atexitand no loop exit, so the reset registry drops the ready queue, the tick queue, and the unjudged rejection ledger. Fibers the host left parked on a promise it never settled are abandoned — exactly as the loop abandons them at exhaustion — and the count arms the same RC-audit skip the executable lane already uses, so the per-session zero-live-heap seam still reports a real leak rather than a host that walked away from its own continuation.Tests
tests/harness/ffi-drain.test.ts+tests/ffi-drain/— the executable lane against a native host that owns the thread. A continuation scheduled in re-entry 1 is still unrun at re-entry 2; it runs at the drain; a promise the host settles from its own callback queues rather than resumes, and completes at the next drain. A second case drains with anasyncframe suspended beneath the native call and keepsawaiting afterwards. Both backends.tests/harness/library-mode.test.tsK15 +tests/library-mode/async/— the same story through<prefix>_drain, plus the archive symbol audit above. Both emissions.tests/harness/library-asyncfree.test.ts— the gate's new line:async/await/promises/queueMicrotaskadmitted, top-levelawaitandfs.promisesrefused, timers/child/signals/stream unchanged; plus themoduleUsesAsynclink-gate fact.tests/harness/library-profile.test.ts—abi.drain_symbolresolution, prefix enforcement, and symbol-collision refusal.The new tests were mutation-checked. With
scr_drain_jobs()stubbed to a no-op that returns immediately, both lanes fail in both backends with exactly the reported symptom (the flag never flips, the await never completes). With the fiber save/restore removed, the async-caller case aborts withscriptc: internal error: await outside an async function.Acceptance against a real embedder
Built janela against this branch with three lines added to its webview shim (
scriptc_drain()after each re-entry into TypeScript) and re-ran the probe from the issue.Before — the reported failure:
After:
The bare
Promise.resolve().then()runs, and so does a.thenon a promise resolved inside the host's own scheduler callback — the precise gap the issue names.PROBE setTimeout RANdeliberately still does not appear: asetTimeoutis a timer, the drain owns no clock, and the host keeps ownership of time.What I did not run
Honest gaps in my own verification, so they are not a surprise in CI:
library-crossbeyond the darwin fixtures, iOS/Android archives) and the sandboxed Linux lane (pnpm test:sandbox) — no zig toolchain or Sandbox credentials on this machine. The library-mode fixtures I added run on darwin only, like the existing ones.fetch-conformance×3,fetch×5,cache-warm×1 and 3 corpus programs ×2 backends — all from Node 24.20 against the pinned 24.15 oracle. They recur identically on an unmodifiedmain, so they are not this change, but I could not clear them to prove a fully green run.llvm differential corpus > 1570-child-unref-kill-reffed.ts, appeared once under 8 workers and passes 3/3 in isolation; the runtime's own comments flag that case as timing-sensitive. Reporting it rather than quietly re-running until green.Happy to rework any of the design decisions above — particularly the
SC4005line (top-levelawaitandfs.promisesrefused,queueMicrotaskadmitted), which is the part most likely to be a judgement call rather than a fact.