vercel-labs / vercel-labs/scriptc

Embedding: the event loop is unreachable while the host owns the main thread (pump entry point + off-thread fs)

Open
#260 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
4.9k
Forks
125
Avg merge
2h 14m
Merged PRs (30d)
95

Description

This is a design/roadmap question rather than a bug, filed because we appear to be an unusual consumer: an embedder that takes the main thread, parks it inside an FFI call for hours, and expects TypeScript to keep making progress. scriptc's model assumes a program that owns main() and runs to completion, which is a perfectly reasonable assumption — this issue is about what it would take to also support the other shape.

We're building janela: a desktop framework where the app's backend is TypeScript compiled by scriptc and the window is the OS webview driven through a C++ shim, so binaries carry no JS engine (~450 KB, no Node, no Chromium). The compiler makes that possible at all, which is why we've stuck with it — thank you.

The single fact that shapes everything

webview_run() is a blocking C call that owns the UI event loop for the app's entire life, and we reach it through FFI. So scriptc's event loop never gets another turn while the window is open. Verified on 0.0.35:

import { readFile } from "node:fs/promises";
declare function nativeBlock(ms: number): number;   // FFI: nanosleep

let fired = false;
readFile("/etc/hosts", "utf8").then(() => { fired = true; });
console.log("A: just scheduled, fired =", fired);
const t = nativeBlock(400) + 0;
console.log("B: after 400ms inside the FFI call, fired =", fired);
console.log("D: main body done — the loop only gets to run now");
A: just scheduled, fired = false
B: after 400ms inside the FFI call, fired = false
D: main body done — the loop only gets to run now
C: promise settled

In an embedded app the "main body done" line never arrives until the user closes the window, so await, setTimeout, promise continuations and fs/promises are all inert for the process's useful lifetime. (invoke: "foreign" in format 5 doesn't help: a probe firing it from a worker thread during a 400 ms blocking call saw delivery only after the call returned, because it marshals to the same parked loop.)

What we built to work around it, and would rather delete

A C++ ticker thread that only sleeps and calls webview_dispatch, so a retained TS callback runs on the UI thread every ~16 ms; a deferred-job pool so an invoke's answer can be produced on a later turn; and a worker-thread file reader with its completion drained on that tick. It works well — but it is an event loop and a thread pool living beside yours, which is not where that code belongs.

Three things that would help, smallest first

1. A pump entry pointscriptc_run_once() / poll(): "run pending timers, microtasks and completions, then return." Our ticker would call it instead of our own callback, and await/setTimeout/fs/promises would start working inside an embedded app with no machinery on our side. This is the shape embedders normally get (uv_run(UV_RUN_NOWAIT)), and for us it is by far the highest value per unit of work.

2. Thread-pool file I/O behind fs/promises. Today the read happens inline. Call-time scales with file size, where a genuine off-thread submit would be flat:

file scriptc fs/promises call returns after Node, for reference
1 KB 1 ms 0 ms
4 MB 1 ms 0 ms
40 MB 8 ms 0 ms

(readFileSync on the same 40 MB file: 11 ms — so most of the work is happening in the call.) Note node:fs currently exports only *Sync plus rename/watch, so fs/promises is the only async surface.

The libuv design applies directly and needs no thread-safety work first: the worker touches only a byte buffer, and the loop thread wraps those bytes into a runtime string and settles the promise. That is exactly the invariant we enforce in our shim — workers never call into the runtime — and it's about a hundred lines there. Happy to point at the code if it's useful.

3. A thread-safe runtime (#257). The big one; it would let embedders run handlers on worker threads and make await work naturally, at which point most of our machinery retires.

The dependency worth knowing

For an ordinary program that owns main(), 2 alone is a win. For an embedder, 2 is useless without 1 — a promise that resolves off-thread still cannot be delivered into a parked loop. And 1 alone is already valuable to us even if 2 and 3 never happen.

Entirely understood if embedding isn't a direction you want to take; in that case a sentence in the docs saying the runtime expects to own the program's lifetime would have saved us a fair amount of investigation, and would set expectations for the next person who tries this.

Related: #255 (Windows link), #256 (stdlib not tree-shaken), #257 (thread-safety), #258 (quadratic string append), #259 (no linker flags).

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the issue's three requested areas: a scriptc_run_once()/poll() pump, off-thread fs/promises I/O, and thread safety in #257. Trace how webview_run() blocks the loop and how fs/promises currently completes, then determine which proposal can be scoped independently. Done requires an agreed embedding direction and explicit API or documentation expectations.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, typescript
Domain
backend-api-design, compilers
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.