Skip to content

feat: a host-callable job checkpoint, and async in library mode - #288

Open
mmamedel wants to merge 1 commit into
vercel-labs:mainfrom
mmamedel:feat/host-microtask-drain
Open

feat: a host-callable job checkpoint, and async in library mode#288
mmamedel wants to merge 1 commit into
vercel-labs:mainfrom
mmamedel:feat/host-microtask-drain

Conversation

@mmamedel

@mmamedel mmamedel commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #265.

Embedders cannot offer async handlers 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 refuses async outright.

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 pending process.nextTick and 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 of scr_loop_run 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 (that decision belongs to a complete turn — the loop's, in the executable lane).

Two spellings:

  • Executable lanescriptc_drain(), the one runtime symbol a native host linked into a scriptc executable is invited to call:

    extern bool scriptc_drain(void);
    
    void host_loop(void) {
      for (;;) {
        on_frame();       /* a retained script-thread callback */
        scriptc_drain();  /* its continuations run here */
        wait_for_next_frame();
      }
    }
  • Library modeabi.drain_symbol in the profile emits <prefix>_drain beside <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 async function. The drain therefore runs deliberately as the program's main context and restores the suspended frame's current-fiber, exception cell, and AsyncLocalStorage context before returning, the same restore scr_async_spawn already does around an eager spawn. Without it the suspended frame's next await parks nothing and the program dies on await outside an async function; that is pinned by its own test.

It returns false when 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 returns false, matching how a script-thread FFI callback is already suppressed in that situation.

Library mode admits async

SC4005 now refuses only the event-loop and ambient-process surface a loop turn would have to service. async functions, await, promise values and queueMicrotask are admitted — a continuation is queued work, and the host runs the queue.

Two async shapes stay refused, because neither has a host-drain story:

  • a top-level await — library init is module evaluation, and nothing at the ABI boundary can wait for it; a half-evaluated graph would look initialized;
  • generators, which suspend for a consumer rather than for a job queue.

fs.promises also 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.

queueMicrotask was only ever refused by namespace accident — its IrLibFn spelling lives under timers., 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_free stops being a structural assertion and becomes a computed fact of the graph, beside deterministic.

A latent link bug this also closes

process.nextTick was already admitted by the gate — no refusal prefix covers it and no coarse predicate sees it — but scr_next_tick lives in scr_async.c, which no library archive linked. On main today:

$ scriptc build --lib --profile profile.json     # entry calls process.nextTick
/tmp/ntprobe/.scriptc/lib.lib.a
$ nm -u lib.lib.a | grep scr_next_tick
_scr_next_tick                                    # …and nothing defines it

The archive builds and is unlinkable. K1's "no prefix-carrying undefineds" cannot see it because the symbol carries no profile prefix. The same moduleUsesAsync gate that links the unit for promises links it for process.nextTick and queueMicrotask too, 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

v1 library artifacts link no event loop, install no signal handlers, and create no threads.

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_LIB flavor fences out:

  • scr_loop_run itself;
  • the timer min-heap, the whole setTimeout/setInterval/ref/unref/refresh surface, and the loop's monotonic clock;
  • the check phase (setImmediate, clearImmediate) and clearInterval;
  • every loop hook (scr_loop_set_io/events/net/dgram/watch/ffi/stream, the island deadline) and scr_loop_has_ready;
  • fs.promises and timers/promises;
  • process.getActiveResourcesInfo's census over the loop's own bookkeeping;
  • the unhandled-rejection verdict.

nm over an async library archive shows promises, fibers, generators and the two job queues — and no scr_loop_run, no scr_set_timeout, no poll, no child-process hook. The K8 mechanical ambient audit (no undefined reference to sigaction, signal, pthread_create, atexit, setvbuf) is untouched, and K15 re-applies K8's loopish check to the archive that does carry the promise unit.

Session teardown is the other new obligation: a library has no atexit and 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 an async frame suspended beneath the native call and keeps awaiting afterwards. Both backends.
  • tests/harness/library-mode.test.ts K15 + 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/queueMicrotask admitted, top-level await and fs.promises refused, timers/child/signals/stream unchanged; plus the moduleUsesAsync link-gate fact.
  • tests/harness/library-profile.test.tsabi.drain_symbol resolution, 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 with scriptc: 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:

PROBE microtask-scheduled
PROBE setTimeout-scheduled
PROBE bridged-scheduled

After:

PROBE microtask-scheduled
PROBE microtask-then RAN
PROBE setTimeout-scheduled
PROBE bridged-scheduled
PROBE bridged-then RAN: bridged

The bare Promise.resolve().then() runs, and so does a .then on a promise resolved inside the host's own scheduler callback — the precise gap the issue names. PROBE setTimeout RAN deliberately still does not appear: a setTimeout is 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:

  • Cross targets (library-cross beyond 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.
  • The full suite has 15 failures on my host both before and after this branchfetch-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 unmodified main, so they are not this change, but I could not clear them to prove a fully green run.
  • One further failure, 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 SC4005 line (top-level await and fs.promises refused, queueMicrotask admitted), which is the part most likely to be a judgement call rather than a fact.

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
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In scr_drain_jobs(), when a caller fiber is parked beneath an outbound FFI call and a continuation throws during the drain, the pending exception lands on main's cell but is stranded when the caller's (empty) cell is unconditionally restored, silently swallowing the throw.

Fix on Vercel

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.

Embedders cannot offer async handlers: microtasks never drain while the host owns the thread (and library mode refuses async)

1 participant