Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to scriptc will be documented in this file.

## Unreleased

### Features

- **Hosts that own the main thread can run promise continuations.** A native host embedded in a scriptc executable calls `scriptc_drain()` between re-entries into the program; a library artifact exports the same checkpoint as `<prefix>_drain` when its profile declares `abi.drain_symbol`. The checkpoint runs the pending `process.nextTick` and promise-job queues to exhaustion and returns — no clock, no timer, no poll, no thread, no signal handler, and no loop.
- **Library mode admits `async` functions, `await`, promises, and `queueMicrotask`.** `SC4005` now refuses only the event-loop and ambient-process surface a loop turn would have to service; a top-level `await` and generators stay refused because neither has a host-drain story. Library archives that reach a continuation link the promise/fiber unit — still without its loop, which is fenced out of the library flavor — so the v1 contract (no event loop, no signal handlers, no threads) is unchanged. The contract sidecar's `async_free` is now computed from the graph instead of asserted structurally.

<!-- release:start -->

## 0.0.36
Expand Down
26 changes: 26 additions & 0 deletions docs/src/app/ffi/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,32 @@ Foreign delivery is deliberately fire-and-forget. It requires `lifetime: "retain

If a script-thread callback throws, the adapter returns zero (or `void`) to native code and suppresses further script callback execution while the exception is pending. When the outer native function returns, the original exception resumes through scriptc's ordinary catchable unwind path. Native work performed between the callback's return and the outer function's return is not rolled back. A foreign callback's native trampoline has already returned before its closure runs; a throw therefore follows event-loop callback semantics instead.

## Running promise continuations from the host

While the program is parked inside an outbound call, scriptc's event loop is not running. A promise resolved during a retained callback therefore *queues* its continuation and nothing runs it until the program's main body returns — which, for a program that hands control to a host loop and never comes back, means at exit.

Hosts that own the main thread call `scriptc_drain` to say "run what is ready, then return":

```c
#include <stdbool.h>

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();
}
}
```

`scriptc_drain` runs the pending `process.nextTick` and promise-job queues to joint exhaustion and returns. It is not a loop turn: it reads no clock, fires no timer, polls no descriptor, creates no thread, installs no signal handler, and reaches no unhandled-rejection verdict — those stay with the program's own loop. Timers scheduled inside the program still wait for it.

Call it only from the thread the program runs on, and only between callbacks — not from inside one, and never from a foreign thread. It is safe when the outbound call that parked the program was made from inside an `async` function: the drain runs as the program's main context and restores the suspended one before returning. It returns `false` when a continuation threw; the exception stays pending and resumes through the ordinary unwind when the outer native call returns, so 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 callback is suppressed in the same situation.

Library artifacts get the same checkpoint under their own symbol: declare `abi.drain_symbol` in the profile and the archive exports `<prefix>_drain` beside `<prefix>_reset`. That is also what makes `async` functions usable in a library module graph.

## Manifest fields

<dl>
Expand Down
16 changes: 16 additions & 0 deletions packages/compiler/src/backend/c/c-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,22 @@ export class CEmitter {
``,
);
}
if (lib.drainSymbol !== null) {
// The job checkpoint. Deliberately NOT arena-resetting: a host
// typically drains right after reading a result, and the declared
// reset posture exists precisely so the host says when results die.
// The runtime half is scr_drain_jobs (scr_async.c, gated into the
// link by this very symbol); an escaping throw from a continuation
// takes the init entry's escaped-exception path to the sink.
out.push(
`void ${lib.drainSymbol}(void) {`,
` scr_library_entry(false, "${lib.drainSymbol}");`,
` scr_drain_jobs(); /* nextTick + promise jobs to exhaustion; no turn */`,
` scr_library_check_exc();`,
`}`,
``,
);
}
for (const e of lib.exports) {
const params: string[] = [];
const args: string[] = [];
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler/src/backend/llvm/emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,9 @@ class LlEmitter {
this.declare(`declare void @scr_library_callback_entry_guard(ptr)`);
this.declare(`declare void @scr_library_arena_reset()`);
this.declare(`declare void @scr_library_collect()`);
if (this.mod.lib.drainSymbol !== null) {
this.declare(`declare i1 @scr_drain_jobs()`);
}
if ((this.mod.lib.callbacks?.length ?? 0) > 0) {
// Host-callback channels: the registration define's dispatch
// (strcmp over the declared names + the runtime slot store).
Expand Down Expand Up @@ -1434,6 +1437,7 @@ class LlEmitter {
if (lib.callbackRegisterSymbol !== null && lib.callbackRegisterSymbol !== undefined) emitSymConst(lib.callbackRegisterSymbol);
if (lib.resultResetSymbol !== null) emitSymConst(lib.resultResetSymbol);
if (lib.collectSymbol !== null) emitSymConst(lib.collectSymbol);
if (lib.drainSymbol !== null) emitSymConst(lib.drainSymbol);
for (const e of lib.exports) emitSymConst(e.symbol);
out.push(``);
// The runtime detected-trap overlay table (scr_runtime.h declares it,
Expand Down Expand Up @@ -1561,6 +1565,20 @@ class LlEmitter {
``,
);
}
if (lib.drainSymbol !== null) {
// The job checkpoint, line for line with the C emission (see there
// for why it does not reset the arena).
out.push(
`define void @${lib.drainSymbol}() ${FN_ATTRS} {`,
`entry:`,
` call void @scr_library_entry(i1 zeroext false, ptr ${symConst(lib.drainSymbol)})`,
` %drained = call i1 @scr_drain_jobs() ; nextTick + promise jobs; no turn`,
` call void @scr_library_check_exc()`,
` ret void`,
`}`,
``,
);
}
for (const e of lib.exports) {
const params: string[] = [];
const body: string[] = [` call void @scr_library_entry(i1 zeroext ${autoReset ? "true" : "false"}, ptr ${symConst(e.symbol)})`];
Expand Down
29 changes: 22 additions & 7 deletions packages/compiler/src/backend/native-toolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,12 +955,19 @@ export function cacheTargetIdentity(
* recompiles only the changed buckets; exact repeats use the merged-object or
* completed-archive tiers and never repeat the merge.
* The base set narrows from the executable lane's unconditional sources:
* scr_async.c (fibers, timers, the loop) and scr_child.c drop — the
* async_free refusal already guarantees nothing references them — and
* scr_library.c (sink, arena, reset registry, library funnel) joins. The gated
* units a library may reach are the pure-data ones (regex + the vendored matcher, assert,
* inspect, symbol, searchParams, emitter+dyn_handle, zlib); every
* loop-hooked or ambient unit was refused at SC4005 before emission.
* scr_async.c and scr_child.c drop from the UNCONDITIONAL set and
* scr_library.c (sink, arena, reset registry, library funnel) joins.
* scr_child.c never comes back — the SC4005 refusal guarantees nothing
* references it. scr_async.c returns as a GATED unit when the graph
* reaches a promise or the profile declares the drain entry, and it
* returns loop-free: its SCR_LIB flavor fences scr_loop_run out entirely,
* so the archive defines no loop entry and references no poll, sleep, or
* child-process primitive. What remains is the promise/fiber machinery
* and the two job queues the host's drain entry services. The other
* gated units a library may reach are the pure-data ones (regex + the
* vendored matcher, assert, inspect, symbol, searchParams,
* emitter+dyn_handle, zlib); every loop-hooked or ambient unit was
* refused at SC4005 before emission.
* External-symbol contract: undefined references only to the target's C/math
* runtime and system APIs. Windows embedders additionally link advapi32,
* iphlpapi, and ws2_32; the platform driver supplies its ordinary CRT and
Expand All @@ -969,7 +976,8 @@ export function cacheTargetIdentity(
* archive. */

/** The library base: the executable lane's unconditional sources minus the
* fiber/loop and child-process units, plus the library-mode TU. */
* promise/fiber and child-process units, plus the library-mode TU. The
* promise/fiber unit is gated back in below (`opts.async`). */
const LIB_RUNTIME_SOURCES = [
...EXECUTABLE_RUNTIME_SOURCES.filter(
(f) => f !== "scr_async.c" && f !== "scr_child.c" && f !== "scr_ffi.c",
Expand Down Expand Up @@ -1030,6 +1038,9 @@ export interface LibArchiveOptions {
* archive, byte-for-byte. */
threadInstances?: boolean;
/** IR-detected link gates (the compileC precedent, refusal-narrowed). */
/** The promise/fiber unit (scr_async.c under -DSCR_LIB: no loop, no
* timers heap consumer, no child hooks). */
async?: boolean;
regex?: boolean;
assert?: boolean;
inspect?: boolean;
Expand Down Expand Up @@ -1160,6 +1171,10 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
// Zig's musl sysroot does not provide arc4random_buf. Keep the fallback
// inside the archive so library embedders need no extra system library.
...(isMuslTarget(driver) ? ["scr_musl.c"] : []),
// The promise/fiber unit, loop-free under -DSCR_LIB. Absent unless the
// graph reaches a continuation or the profile declares the drain
// entry, so a promise-free library keeps its exact archive.
...(opts.async ? ["scr_async.c"] : []),
...(regex ? ["scr_regex.c"] : []),
...(opts.assert || regex || opts.symbol ? ["scr_assert.c"] : []),
...(opts.inspect ? ["scr_inspect.c"] : []),
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/coverage/surface-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ const COVERAGE_NOTES: string[] = [
`The engine-free fetch projection targets Node ${NODE24_FETCH_COMPAT_PROFILE.target.node} with bundled Undici ${NODE24_FETCH_COMPAT_PROFILE.target.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`,
"The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static, dynamic-only, and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's event-loop refusal and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];

Expand Down
17 changes: 11 additions & 6 deletions packages/compiler/src/diagnostics/diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -857,18 +857,23 @@ export function libAsyncExportDiag(
};
}

/** SC4005 — the library module graph reaches event-loop or ambient-process
* surface (async functions, generators, timers, sockets, signals, child
* processes, ...): async_free is a v1 REQUIREMENT, derived from the module
* graph, never observed at runtime. */
/** SC4005 — the library module graph reaches EVENT-LOOP or ambient-process
* surface (timers, sockets, signals, child processes, generators, ...): a
* static fact of the module graph, never observed at runtime.
*
* Promises, `await`, and `async` functions are NOT in this family. They
* need a job queue, and a host drains that queue itself through the
* profile's `abi.drain_symbol`; they need no clock, no poller, and no
* turn. What stays refused is everything a loop TURN would have to
* service — which is why the artifact still links no loop. */
export function libAsyncSurfaceDiag(surface: string, loc: SrcLoc): ScrDiagnostic {
return {
code: "SC4005",
message: `library mode requires an async_free module graph, and this graph reaches ${surface}`,
message: `library mode refuses the event-loop and ambient-process surface, and this graph reaches ${surface}`,
loc,
hint:
"v1 library artifacts link no event loop, install no signal handlers, and create no threads — remove the surface from " +
"everything the entry module reaches",
"everything the entry module reaches (async functions and promises are admitted: drain their continuations from the host)",
};
}

Expand Down
16 changes: 14 additions & 2 deletions packages/compiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
import { validateSidecar } from "./library/sidecar-validate.js";
import { entryFunctionExports, type EntryExportInfo } from "./frontend/lib-exports.js";
import { entryContractFacts, type ContractFacts } from "./frontend/lib-contract.js";
import { moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/ir.js";
import { moduleCoroutineSurface, moduleLibAsyncSurface, moduleLibNondeterministicSurface, moduleEmbedsBuiltin, moduleEmbedsCompressedNpm, moduleUsesAssert, moduleUsesAsync, moduleUsesCopying, moduleUsesDc, moduleUsesDgram, moduleUsesDynAsync, moduleUsesDynInvoke, moduleUsesEmitter, moduleUsesFetch, moduleUsesFileHandle, moduleUsesFsWatch, moduleUsesHttp2, moduleUsesHttpServer, moduleUsesInspect, moduleUsesLegacyTextDecoder, moduleUsesNet, moduleUsesNodeTest, moduleUsesParseArgs, moduleUsesProcessEvents, moduleUsesQs, moduleUsesRegex, moduleUsesSearchParams, moduleUsesStream, moduleUsesSymbol, moduleUsesTls, moduleUsesTlsCa, moduleUsesZlib, type IrFfiImport, type IrLibSection, type IrModule, type IrRecordShape, type IrType, type SrcLoc } from "./ir/ir.js";
import { serializeModule } from "./ir/serialize.js";
import { validateModule } from "./ir/validate.js";
import { canonicalBuiltinModule, checkPreflight, isNodeTypesPath, loadProgram, locOf, requiresOf, resolveNpmImport, type LoadResult } from "./frontend/program.js";
Expand Down Expand Up @@ -1566,7 +1566,10 @@ async function compileTracked(
return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]);
}
if (opts.backend === "c" || outputKind === "c") {
const asyncSurface = moduleLibAsyncSurface(lowered.module);
// The COROUTINE question, not the library gate's: this backend has
// no resumable-stack lowering, so an async function is refused here
// even though a library artifact would now admit it.
const asyncSurface = moduleCoroutineSurface(lowered.module);
if (asyncSurface !== null) {
return fail([
backendRefusalDiag("c", "wasm32-wasi", asyncSurface.surface, asyncSurface.loc),
Expand Down Expand Up @@ -2031,6 +2034,7 @@ function resolveLibrarySection(
sinkRegisterSymbol: profile.sinkRegisterSymbol,
collectSymbol: profile.collectSymbol,
resultResetSymbol: profile.resultResetSymbol,
drainSymbol: profile.drainSymbol,
threadInstances: profile.instancePerThread,
// Host-callback channels: declaration order is the runtime slot
// assignment, and the unregistered-call trap text is assembled HERE,
Expand Down Expand Up @@ -2256,6 +2260,11 @@ function libraryNativeFeatures(
): EarlyLibraryNativeFeatures {
return {
backend,
// The promise/fiber unit. A graph that reaches a continuation needs
// it, and so does a profile that declares the drain entry (whose body
// calls straight into the queue). Everything else keeps the archive a
// pre-drain scriptc produced, byte for byte.
async: moduleUsesAsync(mod) || mod.lib?.drainSymbol != null,
regex: moduleUsesRegex(mod),
assert: moduleUsesAssert(mod),
inspect: moduleUsesInspect(mod),
Expand All @@ -2276,6 +2285,7 @@ function libraryLocalizeSymbols(profile: LibraryProfile): string[] | undefined {
profile.sinkRegisterSymbol,
...(profile.collectSymbol !== null ? [profile.collectSymbol] : []),
...(profile.resultResetSymbol !== null ? [profile.resultResetSymbol] : []),
...(profile.drainSymbol !== null ? [profile.drainSymbol] : []),
...(profile.callbackRegisterSymbol !== null ? [profile.callbackRegisterSymbol] : []),
...(profile.sidecar !== null
? [profile.sidecar.buildIdSymbol, profile.sidecar.abiVersionSymbol]
Expand Down Expand Up @@ -2340,6 +2350,7 @@ async function compileLibraryNative(
optimization: profile.optimization,
...(localizeSymbols !== undefined ? { localizeSymbols } : {}),
...(profile.instancePerThread ? { threadInstances: true } : {}),
async: features.async,
regex: features.regex,
assert: features.assert,
inspect: features.inspect,
Expand Down Expand Up @@ -2824,6 +2835,7 @@ async function compileLibraryTracked(
buildId,
sourceHash,
deterministic: moduleLibNondeterministicSurface(mod) === null,
asyncFree: !moduleUsesAsync(mod),
});
if (!built.ok) return fail(built.diagnostics);
const violations = validateSidecar(built.doc);
Expand Down
Loading
Loading