diff --git a/CHANGELOG.md b/CHANGELOG.md index 35574f64c..6629f1eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `_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. + ## 0.0.36 diff --git a/docs/src/app/ffi/page.mdx b/docs/src/app/ffi/page.mdx index 1b4effd14..98fcf4a7d 100644 --- a/docs/src/app/ffi/page.mdx +++ b/docs/src/app/ffi/page.mdx @@ -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 + +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 `_drain` beside `_reset`. That is also what makes `async` functions usable in a library module graph. + ## Manifest fields
diff --git a/packages/compiler/src/backend/c/c-emitter.ts b/packages/compiler/src/backend/c/c-emitter.ts index 6980f0a4a..823983e11 100644 --- a/packages/compiler/src/backend/c/c-emitter.ts +++ b/packages/compiler/src/backend/c/c-emitter.ts @@ -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[] = []; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 0b4e12ad2..8bf19cdba 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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). @@ -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, @@ -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)})`]; diff --git a/packages/compiler/src/backend/native-toolchain.ts b/packages/compiler/src/backend/native-toolchain.ts index c2e7fbcfc..a30756fe1 100644 --- a/packages/compiler/src/backend/native-toolchain.ts +++ b/packages/compiler/src/backend/native-toolchain.ts @@ -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 @@ -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", @@ -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; @@ -1160,6 +1171,10 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise // 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"] : []), diff --git a/packages/compiler/src/coverage/surface-manifest.ts b/packages/compiler/src/coverage/surface-manifest.ts index 39f463878..6759f502b 100644 --- a/packages/compiler/src/coverage/surface-manifest.ts +++ b/packages/compiler/src/coverage/surface-manifest.ts @@ -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.", ]; diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts index f5dfe44f8..4bf281169 100644 --- a/packages/compiler/src/diagnostics/diagnostic.ts +++ b/packages/compiler/src/diagnostics/diagnostic.ts @@ -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)", }; } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 45a89e27e..3751b63fc 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -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"; @@ -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), @@ -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, @@ -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), @@ -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] @@ -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, @@ -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); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 0d89fa2a0..84e04f2d3 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1024,6 +1024,10 @@ export interface IrLibSection { /** The declared result-arena reset entry; null selects the auto-reset * posture (every entry prologue resets the arena). */ resultResetSymbol: string | null; + /** The declared job-checkpoint entry: "run the pending promise + * continuations, then return". Null when the profile declares none — + * the byte-identical artifact a drain-free profile always produced. */ + drainSymbol: string | null; /** Thread-instanced state (the profile's abi.instance_per_thread): both * backends emit the program TU's mutable statics — module globals, * run-once guards, and the lazily-compiled regex literal caches — as @@ -6217,6 +6221,49 @@ export function moduleUsesDc(mod: IrModule): boolean { return found; } +/** True when the module reaches the PROMISE/FIBER machinery: an async + * function, an await, a promise value, or one of the Promise statics. + * Library mode's link switch for scr_async.c (the assert gating + * precedent): a graph with no continuation to run keeps the exact archive + * a pre-drain scriptc produced, byte for byte. The executable lane links + * that unit unconditionally and ignores this. + * + * Note what it does NOT ask: whether a LOOP is needed. It never is in a + * library artifact — scr_loop_run is fenced out of the SCR_LIB flavor and + * SC4005 refuses every surface a turn would service. */ +export function moduleUsesAsync(mod: IrModule): boolean { + for (const fn of mod.functions) { + if (fn.async === true) return true; + } + let found = false; + const visit = (v: unknown): void => { + if (found || v === null || typeof v !== "object") return; + if (Array.isArray(v)) { + for (const item of v) visit(item); + return; + } + const node = v as { kind?: unknown; fn?: unknown }; + if ( + node.kind === "promise" || + node.kind === "awaitExpr" || + node.kind === "awaitUnionExpr" || + (node.kind === "libCall" && + typeof node.fn === "string" && + (node.fn.startsWith("promise.") || + // The bare job-queue spellings: both push straight onto the + // queues this unit owns, with no promise value in sight. + node.fn === "process.nextTick" || + LIB_MODE_JOB_QUEUE_LIB_FNS.has(node.fn))) + ) { + found = true; + return; + } + for (const key of Object.keys(v)) visit((v as Record)[key]); + }; + visit(mod); + return found; +} + /** True when the module contains any assert libCall — the link switch * that pulls scr_assert.c into the binary (native-toolchain.ts). scr_regex.c calls the * assert throw/inspect helpers (assert.match lives there), so the regex @@ -6702,15 +6749,80 @@ export function moduleUsesTlsCa(mod: IrModule): boolean { return found; } -/* ── library mode's async_free gate ────────────────────────────────────── - * v1 library mode REQUIRES an async_free module graph (ratified): no async - * functions, no generators, no timers, no event-loop or ambient-process - * surface anywhere the entry reaches — a static fact of the graph, never a - * runtime observation. The detector below answers "what does this graph - * reach that a library artifact cannot link?", first offender with its source anchor; - * the structural consequence (scr_async.c / scr_child.c and every - * loop-hooked unit never join a library link) is safe exactly because this - * refusal ran first. */ +/** First construct in the graph that needs a RESUMABLE NATIVE STACK: an + * async function, a generator, an await, a yield, or a promise value + * (`.then` chains lower to runtime continuation fibers too) — then, as a + * safety net, everything the library gate refuses, which is a superset of + * the loop-hooked surface. wasm32-wasi's C backend has no coroutine + * lowering, so this is what its SC3001 names. + * + * Deliberately NOT moduleLibAsyncSurface: the two questions parted ways + * when library mode began admitting continuations. "Can this graph run + * without a coroutine?" is the WASI C backend's question; "does this graph + * reach a surface a library artifact cannot link?" is the library gate's, + * and it now answers `null` for every one of the constructs above. */ +export function moduleCoroutineSurface(mod: IrModule): { surface: string; loc: SrcLoc } | null { + for (const fn of mod.functions) { + if (fn.async === true) return { surface: `an async function ('${fn.name.replace(/^%/, "")}')`, loc: fn.loc }; + if (fn.generator !== undefined) { + return { surface: `a generator function ('${fn.name.replace(/^%/, "")}')`, loc: fn.loc }; + } + } + const kinds: ReadonlyMap = new Map([ + ["promise", "promise values"], + ["generator", "generator values"], + ["awaitExpr", "await"], + ["awaitUnionExpr", "await"], + ["yieldExpr", "yield"], + ]); + const entryLoc: SrcLoc = { file: mod.sourceFile, start: 0, end: 0 }; + let found: { surface: string; loc: SrcLoc } | null = null; + const visit = (v: unknown, loc: SrcLoc): void => { + if (found !== null || v === null || typeof v !== "object") return; + if (Array.isArray(v)) { + for (const item of v) visit(item, loc); + return; + } + const node = v as { kind?: unknown; loc?: SrcLoc }; + const here = node.loc ?? loc; + if (typeof node.kind === "string") { + const bad = kinds.get(node.kind); + if (bad !== undefined) { + found = { surface: bad, loc: here }; + return; + } + } + for (const key of Object.keys(v)) visit((v as Record)[key], here); + }; + visit(mod, entryLoc); + if (found !== null) return found; + return moduleLibAsyncSurface(mod); +} + +/* ── library mode's event-loop gate ────────────────────────────────────── + * v1 library mode refuses every EVENT-LOOP and ambient-process surface + * anywhere the entry reaches — timers, sockets, signals, child processes, + * generators — a static fact of the graph, never a runtime observation. + * The detector below answers "what does this graph reach that a library + * artifact cannot link?", first offender with its source anchor; the + * structural consequence (scr_child.c and every loop-hooked unit never + * join a library link, and scr_async.c joins WITHOUT its loop — the + * `#ifndef SCR_LIB` fence around scr_loop_run) is safe exactly because + * this refusal ran first. + * + * Promises, `await`, and `async` functions are deliberately NOT in the + * refused family. A continuation is queued work, not a turn: running one + * needs the job queue and nothing else — no clock, no poller, no thread, + * no signal handler. The host owns the moment it happens, through the + * profile's `abi.drain_symbol`. Two async shapes stay refused because + * they are not the host's to drain: + * + * - an async ENTRY module (top-level await): library init IS module + * evaluation, and the host has no promise to wait on and no loop to + * finish it — a half-evaluated module graph would look initialized; + * - generators, which suspend for a CONSUMER rather than a job queue and + * have no drain story at the ABI boundary. + */ /** libCall families a v1 library artifact refuses, with the surface name the SC4005 * teaching uses. Prefix match over IrLibFn spellings. */ @@ -6719,6 +6831,11 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ // its fire rides the timer queue (scr_bytes_io.c), which library links // exclude — refuse the surface like the rest of the event-loop family. ["fs.existsChk", "the async fs callback surface (fs.exists)"], + // fs.promises mints already-settled promises (the syscalls are + // synchronous), so the queue is not what stops it — its handle surface + // reaches units outside the library link set. Refused as one family + // rather than admitted piecewise. + ["fsp.", "the fs.promises surface"], ["fs.renameCb", "the async fs callback surface (fs.rename)"], ["process.stdoutWriteBytesCb", "process.stdout.write completion callbacks"], ["process.stderrWriteBytesCb", "process.stderr.write completion callbacks"], @@ -6757,13 +6874,22 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [ ["dyn.defineProps", "checked-dynamic prototype dispatch"], ]; +/** Spellings the prefix table above refuses by NAMESPACE but which are + * pure job-queue work: queueMicrotask lives in the `timers.` IrLibFn + * namespace and is not a timer at all — it pushes the same FIFO a + * promise continuation lands on, so the host's drain runs it and no turn + * is involved. Checked before the prefix scan. */ +const LIB_MODE_JOB_QUEUE_LIB_FNS: ReadonlySet = new Set([ + "timers.queueMicrotask", + "timers.queueMicrotaskDyn", +]); + /** Value/type kinds whose mere presence means an excluded unit's code (or * a fiber) would have to link. */ const LIB_MODE_REFUSED_KINDS: ReadonlyMap = new Map([ - ["promise", "promise values"], + // "promise", "awaitExpr" and "awaitUnionExpr" are deliberately absent: + // a promise is a queued job, and the host drains the queue. ["generator", "generator values"], - ["awaitExpr", "await"], - ["awaitUnionExpr", "await"], ["yieldExpr", "yield"], ["child", "the child_process surface"], ["spawnRes", "the child_process surface"], @@ -6791,7 +6917,13 @@ const LIB_MODE_REFUSED_KINDS: ReadonlyMap = new Map([ * refuses, anchored at the entry). */ export function moduleLibAsyncSurface(mod: IrModule): { surface: string; loc: SrcLoc } | null { for (const fn of mod.functions) { - if (fn.async === true) return { surface: `an async function ('${fn.name.replace(/^%/, "")}')`, loc: fn.loc }; + // Top-level await: the entry function IS module evaluation, which the + // init entry runs synchronously. Nothing at the ABI boundary can wait + // for it, so a graph that needs it is refused rather than silently + // half-initialized. + if (fn.async === true && fn.name === mod.entry) { + return { surface: "top-level await (the entry module's own body is async)", loc: fn.loc }; + } if (fn.generator !== undefined) { return { surface: `a generator function ('${fn.name.replace(/^%/, "")}')`, loc: fn.loc }; } @@ -6812,7 +6944,7 @@ export function moduleLibAsyncSurface(mod: IrModule): { surface: string; loc: Sr found = { surface: bad, loc: here }; return; } - if (node.kind === "libCall" && typeof node.fn === "string") { + if (node.kind === "libCall" && typeof node.fn === "string" && !LIB_MODE_JOB_QUEUE_LIB_FNS.has(node.fn)) { for (const [prefix, surface] of LIB_MODE_REFUSED_PREFIXES) { if (node.fn.startsWith(prefix)) { found = { surface, loc: here }; diff --git a/packages/compiler/src/library/library-cache.test.ts b/packages/compiler/src/library/library-cache.test.ts index df047abb8..6743350d7 100644 --- a/packages/compiler/src/library/library-cache.test.ts +++ b/packages/compiler/src/library/library-cache.test.ts @@ -71,6 +71,7 @@ test("early library cache restores generated artifacts and metadata", async () = }); const native = { backend: "llvm" as const, + async: false, regex: false, assert: true, inspect: false, @@ -122,6 +123,7 @@ test("early library cache publishes after creating a fresh output directory", as cPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -150,6 +152,7 @@ test("early library cache hits refresh every payload's LRU time", async () => { sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -193,6 +196,7 @@ test("early library cache misses on source edits and newly-resolved candidates", sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -220,6 +224,7 @@ test("early library cache misses on source edits and newly-resolved candidates", sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -265,6 +270,7 @@ test("semantic library cache restores and rebases IR after a comment-only edit", sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -322,6 +328,7 @@ test("semantic library cache refuses token and directive edits", async () => { sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -366,6 +373,7 @@ test("semantic C cache accepts only line-preserving single-source edits", async sidecarPath: f.sidecarPath, native: { backend: "c", + async: false, regex: false, assert: false, inspect: false, @@ -402,6 +410,7 @@ test("semantic C cache refuses non-LF separator normalization", async () => { sidecarPath: f.sidecarPath, native: { backend: "c", + async: false, regex: false, assert: false, inspect: false, @@ -459,6 +468,7 @@ test("semantic C cache refuses comment-only edits in multi-source graphs", async sidecarPath: f.sidecarPath, native: { backend: "c", + async: false, regex: false, assert: false, inspect: false, @@ -493,6 +503,7 @@ test("early library cache is separated by the host Node version", async () => { sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -524,6 +535,7 @@ test("early library cache rejects corrupted artifacts and metadata", async () => sidecarPath: f.sidecarPath, native: { backend: "llvm", + async: false, regex: false, assert: false, inspect: false, @@ -556,6 +568,7 @@ test("disabled early library cache performs no reads or writes", async () => { sidecarPath: f.sidecarPath, native: { backend: "llvm" as const, + async: false, regex: false, assert: false, inspect: false, diff --git a/packages/compiler/src/library/library-cache.ts b/packages/compiler/src/library/library-cache.ts index 3b5722968..83065c482 100644 --- a/packages/compiler/src/library/library-cache.ts +++ b/packages/compiler/src/library/library-cache.ts @@ -56,6 +56,9 @@ interface EarlyLibraryCacheStamp { export interface EarlyLibraryNativeFeatures { backend: "c" | "llvm"; + /** The promise/fiber unit (scr_async.c, compiled loop-free under + * SCR_LIB): reached continuations, or a profile-declared drain entry. */ + async: boolean; regex: boolean; assert: boolean; inspect: boolean; @@ -112,6 +115,7 @@ export interface SemanticLibraryCacheHit { } const BOOLEAN_NATIVE_KEYS = [ + "async", "regex", "assert", "inspect", diff --git a/packages/compiler/src/library/library-profile.ts b/packages/compiler/src/library/library-profile.ts index 1a38b81e1..3f5077edf 100644 --- a/packages/compiler/src/library/library-profile.ts +++ b/packages/compiler/src/library/library-profile.ts @@ -21,6 +21,10 @@ * "sink_register_symbol": "_set_panic_sink", * "collect_symbol": "_collect" | null, // session ruling 2 * "result_reset_symbol": "_reset" | null, // §4.3 two postures + * "drain_symbol": "_drain" | null, // the job checkpoint: + * // run the pending + * // promise continuations, + * // then return (see below) * "localize_runtime": false, // multi-instance library * // mode (see below); absent * // = false, the classic @@ -384,6 +388,19 @@ export interface LibraryProfile { /** §4.3: declared → results accumulate until the host calls it; null → * every entry prologue resets the result arena. */ resultResetSymbol: string | null; + /** The job checkpoint: declared → the archive exports an entry that runs + * the pending process.nextTick and promise-job queues to exhaustion and + * returns; null → no such entry (the byte-identical artifact a + * drain-free profile always produced). + * + * Declaring it does NOT give the artifact an event loop. The entry reads + * no clock, fires no timer, polls no descriptor, creates no thread, and + * installs no handler — the v1 contract is unchanged, and the SC4005 + * gate still refuses every surface a loop turn would service. It is the + * point at which a host that owns the thread says "now run what is + * ready", which is what `async` functions in the graph need and all they + * need. */ + drainSymbol: string | null; /** Multi-instance library mode: true localizes every runtime-internal * symbol so N archives with distinct prefixes link into one process (see * the header contract). False (the default) produces the classic @@ -524,7 +541,7 @@ export function loadLibraryProfile( if (abi === null || typeof abi !== "object" || Array.isArray(abi)) { throw new ProfileError("'abi' must be an object"); } - rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "localize_runtime", "instance_per_thread", "callback_register_symbol"]); + rejectUnknownKeys(abi, "abi", ["prefix", "init_symbol", "sink_register_symbol", "collect_symbol", "result_reset_symbol", "drain_symbol", "localize_runtime", "instance_per_thread", "callback_register_symbol"]); const a = abi as Record; const prefix = req(a["prefix"], "abi.prefix", "string"); if (!C_IDENT.test(prefix)) { @@ -534,6 +551,7 @@ export function loadLibraryProfile( const sinkRegisterSymbol = symbolField(a["sink_register_symbol"], "abi.sink_register_symbol", prefix, false)!; const collectSymbol = symbolField(a["collect_symbol"], "abi.collect_symbol", prefix, true); const resultResetSymbol = symbolField(a["result_reset_symbol"], "abi.result_reset_symbol", prefix, true); + const drainSymbol = symbolField(a["drain_symbol"], "abi.drain_symbol", prefix, true); // Multi-instance library mode: strictly boolean when present (the field // gates the artifact's whole link surface, so a truthy non-boolean is a // refusal, never a coercion). @@ -773,6 +791,7 @@ export function loadLibraryProfile( claim(sinkRegisterSymbol, "abi.sink_register_symbol"); claim(collectSymbol, "abi.collect_symbol"); claim(resultResetSymbol, "abi.result_reset_symbol"); + claim(drainSymbol, "abi.drain_symbol"); claim(callbackRegisterSymbol, "abi.callback_register_symbol"); if (sidecar !== null) { claim(sidecar.buildIdSymbol, "sidecar.build_id_symbol"); @@ -890,6 +909,7 @@ export function loadLibraryProfile( sinkRegisterSymbol, collectSymbol, resultResetSymbol, + drainSymbol, localizeRuntime, instancePerThread, callbackRegisterSymbol, diff --git a/packages/compiler/src/library/sidecar.ts b/packages/compiler/src/library/sidecar.ts index 4859cedc4..59a7c8c71 100644 --- a/packages/compiler/src/library/sidecar.ts +++ b/packages/compiler/src/library/sidecar.ts @@ -237,6 +237,7 @@ export function abiExportSuffixes(profile: LibraryProfile): string[] { out.push(strip(profile.initSymbol)); if (profile.collectSymbol !== null) out.push(strip(profile.collectSymbol)); if (profile.resultResetSymbol !== null) out.push(strip(profile.resultResetSymbol)); + if (profile.drainSymbol !== null) out.push(strip(profile.drainSymbol)); for (const e of profile.exports) out.push(strip(e.symbol)); return out; } @@ -1347,6 +1348,11 @@ export interface SidecarBuildInput { buildId: string; sourceHash: string; deterministic: boolean; + /** No continuation anywhere in the compiled graph: no async function, no + * await, no promise value, no queued job. Computed (schema rule V14), + * never defaulted — library mode admits async graphs now, so this is a + * real fact about THIS artifact rather than a structural consequence. */ + asyncFree: boolean; } /** The declared integer slots (ask 4), resolved by the projection into @@ -1610,10 +1616,12 @@ export function buildSidecar(input: SidecarBuildInput): SidecarBuildResult { // is a symbol list carrying no TypeRefs). integer_slots: config.integerSlots.map((e) => ({ slot: e.slot, class: e.cls })), deterministic: input.deterministic, - // Structural in library mode: the SC4005 gate refused any graph - // reaching async/timer/event-loop surface before emission, so a - // sidecar exists only for async_free graphs. - async_free: true, + // No longer structural: SC4005 refuses the event-loop and + // ambient-process surface, but a library graph may reach promises + // and async functions (their continuations are the HOST's to drain, + // through the profile's drain entry). Computed from the graph, like + // `deterministic` beside it. + async_free: input.asyncFree, }; return { ok: true, diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 196ac25d3..c4bdfd920 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -11,7 +11,7 @@ "The engine-free fetch projection targets Node 24.15.0 with bundled Undici 7.24.4. 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." ], "entries": [ diff --git a/packages/compiler/test/library-identity-emission.test.ts b/packages/compiler/test/library-identity-emission.test.ts index 6112e94ea..f371a4d6f 100644 --- a/packages/compiler/test/library-identity-emission.test.ts +++ b/packages/compiler/test/library-identity-emission.test.ts @@ -15,6 +15,7 @@ const libraryModule = (): IrModule => ({ sinkRegisterSymbol: "ie_set_sink", collectSymbol: null, resultResetSymbol: null, + drainSymbol: null, threadInstances: false, exports: [], trapOverlays: [], diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index fd74c1e6c..39a3521e6 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -202,7 +202,25 @@ static void scr_promise_gcfree(void *o) { scr_cyc_free(p); } +#ifdef SCR_LIB +/* The library session's async teardown (defined with the drain below) and + * its one-shot registration flag. */ +static void scr_async_library_teardown(void); +static bool scr_async_library_registered = false; +#endif + ScrPromise *scr_promise_new(void) { +#ifdef SCR_LIB + /* Library artifacts have no atexit and no loop exit, so the SESSION + * reset is where a session's leftover queued work dies. Register that + * teardown on the first promise of the process — the scr_ffi_link + * precedent, and the earliest point at which this unit can have any + * state to drop. */ + if (!scr_async_library_registered) { + scr_async_library_registered = true; + scr_atexit(scr_async_library_teardown); + } +#endif ScrPromise *p = scr_cyc_alloc(sizeof *p, &scr_promise_trace, &scr_promise_gcfree); p->rc = 1; #ifdef SCR_RC_AUDIT @@ -405,6 +423,15 @@ static void scr_ready_push(ScrFiber *f) { scr_ready[scr_ready_head + scr_ready_len++] = f; } +/* ── executable-lane only: the loop's own machinery ────────────────────── + * Everything between here and the matching #endif exists to SERVICE a loop + * turn — the timer heap and its whole surface, the loop's clock, and the + * active-resources census over them. A library artifact has no turn: the + * SC4005 gate refuses every one of these surfaces before emission, so + * compiling them into the archive would publish loop symbols an embedder + * can neither reach nor want. Fenced out, `nm` over a library archive tells + * the same story the contract does. */ +#ifndef SCR_LIB /* Timer min-heap, FIFO tiebreak via sequence numbers. `id` is nonzero only * for setInterval entries (the clearInterval handle; setTimeout has no * clear surface, so plain timeouts never need one) and `repeat_ms` is the @@ -658,6 +685,8 @@ ScrArr *scr_active_resources(void) { return arr; } +#endif /* !SCR_LIB */ + /* ── process.nextTick (the user tick queue) ─────────────────────────── * A FIFO of user callbacks drained BEFORE promise jobs at every loop * checkpoint, to joint exhaustion with them — Node's tick-then-microtask @@ -731,6 +760,7 @@ void scr_nticks_teardown(void) { * engine dies so closures holding engine callbacks free first and the * counting allocator's zero-live audit holds (the loop only exits with * entries still armed on the uncaught/unhandled paths). */ +#ifndef SCR_LIB static void scr_immediates_teardown(void); void scr_timers_teardown(void) { for (size_t i = 0; i < scr_ntimers; i++) scr_closure_release(scr_timers[i].cb); @@ -739,7 +769,10 @@ void scr_timers_teardown(void) { scr_immediates_teardown(); scr_nticks_teardown(); } +#endif /* !SCR_LIB — a library has no timer or immediate to release; its + * session teardown drops the tick queue directly. */ +#ifndef SCR_LIB /* the check phase is a loop station; see the fence above */ void scr_clear_interval(double handle) { if (!(handle >= 1)) return; /* NaN/0/negative: never a handle; Node tolerates */ unsigned long id = (unsigned long)handle; @@ -853,6 +886,8 @@ static void scr_immediates_teardown(void) { scr_reffed_immediates = 0; } +#endif /* !SCR_LIB */ + /* Unhandled rejections: rejected promises retained here until observed. */ static ScrPromise **scr_maybe_unhandled = NULL; static size_t scr_nunhandled = 0, scr_unhandled_cap = 0; @@ -1581,6 +1616,7 @@ ScrPromise *scr_promise_settled_ref(void *v, void *(*retain)(void *), void (*rel return p; } +#ifndef SCR_LIB /* the fs.promises surface is SC4005-refused in a library */ /* ── fs/promises ───────────────────────────────────────────────────── * The promise forms run the SAME sync operations and mint an already- * settled promise (scr_promise_settled_*): success fulfills, a pending @@ -1654,6 +1690,8 @@ ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) { return scr_promise_settled_void(); } +#endif /* !SCR_LIB */ + /* fs.rename(old, new, cb): the static callback surface. The OS operation * starts immediately on a native worker, matching libuv's key contract: * while JS/main is synchronously occupied, the filesystem request can still @@ -1863,7 +1901,7 @@ static bool scr_fs_renames_dispatch(void) { free(op); return true; } -#else +#elif !defined(SCR_LIB) static bool scr_fs_renames_pending(void) { return false; } static bool scr_fs_renames_dispatch(void) { return false; } #endif @@ -1954,6 +1992,7 @@ void scr_fs_rename_thunk0(ScrClosure *cb, ScrError *err) { * delay rides scr_set_timeout's own Node-exact coercion (clamp to 1, * truncate). */ +#ifndef SCR_LIB /* timers/promises + setImmediate: loop stations, refused */ ScrPromise *scr_tp_set_timeout(double ms) { ScrPromise *p = scr_promise_new(); scr_set_timeout(scr_make_resolve(p, 3 /* void */), ms); @@ -2014,6 +2053,8 @@ ScrDyn *scr_set_immediate_dyn_value(void) { * V8's single microtask queue. scr_resume_fiber runs the closure on the * main stack; a throw is an uncaught exception (Node's queueMicrotask), * never a rejection. */ +#endif /* !SCR_LIB */ + void scr_queue_microtask(ScrClosure *cb) { ScrFiber *f = calloc(1, sizeof *f); if (!f) scr_oom(); @@ -2045,6 +2086,7 @@ void scr_queue_microtask_dyn(const ScrDyn *cb) { scr_queue_microtask(c); } +#ifndef SCR_LIB /* every loop hook below is a turn's business, not a host's */ /* ── the event hooks (scr_events.c) ────────────────────────────────── * Process signal/exit events and the piped-stdin surface live in * scr_events.c, which links ONLY into binaries whose IR uses those @@ -2068,6 +2110,8 @@ void scr_loop_set_events(bool (*pending)(void), bool (*watching)(void), scr_events_pollfds_fn = pollfds; } +#endif /* !SCR_LIB */ + /* ── the event loop ───────────────────────────────────────────────────── */ static void scr_resume_fiber(ScrFiber *f) { @@ -2114,6 +2158,7 @@ static void scr_resume_fiber(ScrFiber *f) { } } +#ifndef SCR_LIB /* the loop's sleep caps and its remaining hooks */ /* Reap granularity while children are pending on the POLLING fallback: * the loop polls waitpid(WNOHANG) at least this often instead of sleeping * to the next timer deadline. The primary path has no cap — the sleep @@ -2232,6 +2277,169 @@ void scr_loop_set_stream(bool (*pending)(void), void (*dispatch)(void)) { * microtask checkpoints between macrotasks). */ bool scr_loop_has_ready(void) { return scr_ready_len > 0; } +#endif /* !SCR_LIB */ + +/* ── the job queues, one checkpoint ────────────────────────────────────── + * The two ALWAYS-READY queues, factored out of the loop's checkpoint so + * scr_drain_jobs() below runs exactly the code the loop runs — there is + * one checkpoint implementation, not two that drift. Neither station + * reads the clock, fires a timer, polls an fd, creates a thread, or + * installs a handler: a checkpoint is pure queued work. */ + +/* Every queued process.nextTick callback, FIFO, to exhaustion. False when + * one left an exception pending — the caller owns the uncaught path. */ +static bool scr_run_tick_queue(void) { + while (scr_nt_head != NULL) { + ScrNtick *t = scr_nt_head; + scr_nt_head = t->next; + if (scr_nt_head == NULL) scr_nt_tail = NULL; + ScrClosure *cb = t->cb; + ScrFsRenameFn error_cb = t->error_cb; + void (*raw)(void) = t->raw; + free(t); + if (cb) { + if (error_cb) error_cb(cb, NULL); + else ((void (*)(ScrClosure *))cb->fn)(cb); + scr_closure_release(cb); + } else { + raw(); /* one stream tick, FIFO with the user ticks around it */ + } + if (scr_exc_pending()) return false; + } + return true; +} + +/* The promise-job queue to exhaustion. A job's throw leaves the exception + * pending (scr_resume_fiber's queueMicrotask envelope); every caller + * checks the cell rather than this function's absent result. */ +static void scr_run_ready_queue(void) { + while (scr_ready_len > 0) { + ScrFiber *f = scr_ready[scr_ready_head++]; + scr_ready_len--; + scr_resume_fiber(f); + } +} + +/* ── the host-callable job checkpoint ──────────────────────────────────── + * "Run the pending promise continuations, then return." A host that owns + * the main thread has no loop turn to ride: a native embedder parked + * inside an outbound FFI call re-enters the program through a retained + * callback, and a library-mode host re-enters through an ABI entry. + * Either way the job queues those entries fill are not serviced again + * until the program's main body returns — which for an embedded program + * means "at exit". This is that service, and deliberately nothing more: + * the nextTick and promise-job queues to JOINT exhaustion, the loop's own + * two stations, then a return. + * + * It is 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). Microtasks are not + * timers: the host keeps ownership of time and only says "now run what is + * ready", which is why a library artifact can carry this entry without + * acquiring an event loop. + * + * Returns false when the drain stopped on a pending exception. The cell + * stays pending, so the throw resumes through the ordinary unwind when + * the enclosing native call returns (the FFI script-callback rule) or + * reaches the library funnel at the entry's escaped-exception check. A + * drain entered WITH an exception already in flight runs nothing and + * returns false — the same suppression a script-thread FFI callback takes + * while a throw is pending. */ +bool scr_drain_jobs(void) { + if (scr_exc_pending()) return false; + /* A host may drain with a FIBER still on the stack beneath the native + * call it is parked in — the outbound call was made from inside an + * async function. Resuming a job re-points scr_current, the exception + * cell, and the AsyncLocalStorage context at that job, and then at MAIN + * when the job parks or finishes; the surrounding fiber would come back + * throwing and catching against main's cell. Run the drain as main + * deliberately (so its behavior does not depend on who called it) and + * restore the caller's own three before returning — the same restore + * scr_async_spawn does around an eager spawn. */ + ScrFiber *caller = scr_current; + if (caller != NULL) { + scr_current = NULL; + scr_exc_swap_cell(NULL); + scr_als_active = &scr_als_main_slot; + } + bool drained = true; + while (scr_nt_head != NULL || scr_ready_len > 0) { + if (!scr_run_tick_queue()) { + drained = false; + break; + } + scr_run_ready_queue(); + if (scr_exc_pending()) { + drained = false; + break; + } + } + if (caller != NULL) { + scr_current = caller; + scr_exc_swap_cell(&caller->exc); + scr_als_active = &caller->als; + } + return drained; +} + +#ifndef SCR_LIB +/* The executable lane's host entry: the one runtime symbol a native host + * linked into a scriptc executable is invited to call, named apart from + * the scr_ internals it must not touch. Library artifacts expose the same + * checkpoint under the profile's own prefix (abi.drain_symbol), so this + * unprefixed spelling stays out of their symbol space. */ +bool scriptc_drain(void) { return scr_drain_jobs(); } +#endif + +#ifdef SCR_LIB +/* ── the library session's async teardown ──────────────────────────────── + * Registered on the first promise and run by every session reset + * (scr_library.c's reset registry, the executable lane's atexit). What a + * session can leave behind is exactly what the host never drained: + * + * - queueMicrotask envelopes still in the ready queue — released here, + * like the loop's teardown releases undelivered timer callbacks; + * - the nextTick queue (process.nextTick is admitted — the host's drain + * runs it). There is no timer heap and no immediate queue to drop + * beside it: both are fenced out of this unit's library flavor; + * - promises the checkpoint never got to judge — dropped without a + * verdict, the executable lane's scr_discard_unhandled_rejections; + * - fibers parked on a promise the host never settled. Those are + * ABANDONED, exactly as the loop abandons them at exhaustion: their + * stacks and the values they own are deliberately NOT unwound, because + * unwinding would run user `finally` blocks that nothing ever reached. + * The count arms the same RC-audit skip the executable lane uses, so + * the per-session zero-live-heap seam reports a real leak and not a + * host that walked away from its own continuation. */ +static void scr_async_library_teardown(void) { + while (scr_ready_len > 0) { + ScrFiber *f = scr_ready[scr_ready_head++]; + scr_ready_len--; + /* A microtask envelope owns only its closure; a queued FIBER is + * abandoned like a parked one (its stack is never unwound). */ + if (f->micro_cb != NULL) { + scr_closure_release(f->micro_cb); + free(f); + } + } + scr_ready_head = 0; + scr_ready_len = 0; + scr_nticks_teardown(); + scr_discard_unhandled_rejections(); + scr_note_abandoned_fibers(scr_fibers_live); +} +#endif /* SCR_LIB */ + +#ifndef SCR_LIB +/* The event loop is executable-lane-only. A library artifact links this + * unit for its promises and fibers alone: the SC4005 gate refuses every + * timer, io, child-process, and ambient-process surface a turn would + * service, so the turn itself has nothing to do and no library artifact + * should carry one. Fencing it here is what keeps the v1 promise literal + * — a library archive defines no loop entry, references no poll/sleep + * primitive, and reaches no child-process hook — while the host-callable + * checkpoint above still lets continuations run. */ bool scr_loop_run(ScrPromise *top_level) { /* The FIRST checkpoint after the synchronous main body runs promise * jobs BEFORE the first tick drain: Node's main-module evaluation is @@ -2250,30 +2458,10 @@ bool scr_loop_run(ScrPromise *top_level) { * microtask queue mid-drain (V8 drains it fully). A tick's uncaught * throw ends the loop like any listener's (main reports it). */ if (!first_checkpoint) { - while (scr_nt_head != NULL) { - ScrNtick *t = scr_nt_head; - scr_nt_head = t->next; - if (scr_nt_head == NULL) scr_nt_tail = NULL; - ScrClosure *cb = t->cb; - ScrFsRenameFn error_cb = t->error_cb; - void (*raw)(void) = t->raw; - free(t); - if (cb) { - if (error_cb) error_cb(cb, NULL); - else ((void (*)(ScrClosure *))cb->fn)(cb); - scr_closure_release(cb); - } else { - raw(); /* one stream tick, FIFO with the user ticks around it */ - } - if (scr_exc_pending()) return false; - } + if (!scr_run_tick_queue()) return false; } /* Microtasks to exhaustion (Node: promise jobs before timers). */ - while (scr_ready_len > 0) { - ScrFiber *f = scr_ready[scr_ready_head++]; - scr_ready_len--; - scr_resume_fiber(f); - } + scr_run_ready_queue(); first_checkpoint = false; /* The ESM loader observes a rejected entry evaluation at a promise-job * checkpoint and terminates before later ref'd timers or I/O can run. @@ -2675,7 +2863,13 @@ bool scr_loop_run(ScrPromise *top_level) { scr_note_abandoned_fibers(scr_fibers_abandoned); return rejection_failed; } +#endif /* !SCR_LIB */ +#ifndef SCR_LIB +/* The unhandled-rejection VERDICT is a complete turn's decision, so it + * belongs to the loop. A library host drains jobs; what it does about a + * rejection nothing handled is its own policy, and the session teardown + * drops the ledger without a competing default report. */ /* The island's half of the unhandled-rejection report (scr_island.c * registers it at engine boot): called with print=true when the static * ledger below reported nothing — one report, one voice, like Node's @@ -2782,6 +2976,8 @@ bool scr_report_unhandled_rejections(void) { return any; } +#endif /* !SCR_LIB */ + /* A fatal executable-module rejection suppresses unrelated rejections * created in the SAME checkpoint. Drop their retained ledger references * without delivering process events or a competing default report. */ diff --git a/packages/runtime/src/scr_console.c b/packages/runtime/src/scr_console.c index f05d1832a..c147df88e 100644 --- a/packages/runtime/src/scr_console.c +++ b/packages/runtime/src/scr_console.c @@ -13,6 +13,10 @@ * plain builds satisfy scr_async's reference. */ static SCR_TL long scr_abandoned_fibers = 0; void scr_note_abandoned_fibers(long n) { scr_abandoned_fibers = n; } +/* Read back by the LIBRARY RC audit (scr_library.c), which cannot call + * scr_async.c's own counter: that unit joins a library link only when the + * graph reaches a promise, while this note is always present. */ +long scr_abandoned_fiber_note(void) { return scr_abandoned_fibers; } #ifndef SCR_LIB #ifdef SCR_RC_AUDIT diff --git a/packages/runtime/src/scr_library.c b/packages/runtime/src/scr_library.c index bfe68626c..295e2f440 100644 --- a/packages/runtime/src/scr_library.c +++ b/packages/runtime/src/scr_library.c @@ -396,6 +396,14 @@ extern long scr_bytes_live_count(void); /* scr_bytes.c */ * previous session leaked. A failure is a TRAP through the sink — the * executable audit's _Exit(99) stays exe-lane-only. */ static void scr_library_audit_zero(void) { + /* Fibers the host walked away from — parked on a promise it never + * settled, or queued and never drained — keep their stacks and every + * value those stacks own, by design (unwinding one would run user + * `finally` blocks nothing ever reached). That is the executable + * lane's abandonment story at loop exhaustion, and the audit takes the + * same exemption here rather than reporting a deliberate hold as a + * leak. Draining to quiescence before re-init keeps the seam armed. */ + if (scr_abandoned_fiber_note() > 0) return; long strings = scr_str_live_count(), arrays = scr_arr_live_count(), maps = scr_map_live_count(), boxes = scr_box_live_count(), closures = scr_closure_live_count(), objects = scr_obj_live_count(), diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 8195bd8f1..bf6fdac49 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -4091,6 +4091,30 @@ double scr_now_ms(void); /* the loop's monotonic clock, in ms */ * Returns true when a default/listener-crashing unhandled rejection * already selected and reported exit status 1. */ bool scr_loop_run(ScrPromise *top_level); +/* ── the host-callable job checkpoint (scr_async.c) ───────────────────── + * Runs the pending process.nextTick and promise-job queues to JOINT + * exhaustion — the loop's own two stations — and returns. Deliberately + * not a loop turn: it reads no clock, fires no timer, polls no + * descriptor, creates no thread, installs no handler, and reaches no + * unhandled-rejection verdict. + * + * This is the point at which a host that owns the main thread lets + * continuations run. Native code parked inside an outbound FFI call + * re-enters the program through a retained callback; a library-mode host + * re-enters through an ABI entry. In both shapes the queues those entries + * fill would otherwise wait for the program's main body to return. + * + * False means the drain stopped on a pending exception (or was entered + * with one already in flight): the cell stays pending and resumes through + * the ordinary unwind, so a host never has to unwind script frames + * itself. Executables expose it to their host as `scriptc_drain`; + * library artifacts expose it under the profile's `abi.drain_symbol`. */ +bool scr_drain_jobs(void); +#ifndef SCR_LIB +/* The executable lane's host entry — the ONE runtime symbol a native host + * linked into a scriptc executable is invited to call. */ +bool scriptc_drain(void); +#endif /* External I/O hook, polled at loop quiescence like the child registry: * `pending` keeps the loop alive; `poll` makes progress and may SLEEP up * to max_wait_ms (on real fds — socket readiness wakes it early), so the @@ -4122,6 +4146,7 @@ bool scr_on_fiber(void); * (a host callback may spawn a fiber that re-enters the engine). */ void *scr_fiber_self(void); void scr_note_abandoned_fibers(long n); /* scr_console.c owns the flag */ +long scr_abandoned_fiber_note(void); /* the flag, read back */ /* new Promise(executor): kind 0 f64, 1 bool, 2 str, 3 void; ref-kind * resolve thunks are emitted (they know the concrete RC helpers) over diff --git a/tests/ffi-drain/main.ts b/tests/ffi-drain/main.ts new file mode 100644 index 000000000..2c41e342a --- /dev/null +++ b/tests/ffi-drain/main.ts @@ -0,0 +1,42 @@ +// The embedded shape: the HOST owns the main thread. The program registers +// a retained callback and hands control to a native "run" function that +// does not return until it decides to; every later re-entry into script +// happens through that callback. Nothing here is scriptc-specific — it is +// how a window shell, an audio callback, or any host loop is embedded. +// +// What is asserted is the seam between two re-entries: a continuation +// scheduled during one of them must be able to run before the next one, +// and the ONLY thing that can make that happen is the host's own drain. +declare function nativeHostRegister(handler: (turn: number) => void): void; +declare function nativeHostRun(): number; +declare function nativeHostResolve(resolve: (value: number) => void): void; + +const log: string[] = []; +let flag = "not-run"; +let awaited = "not-resolved"; + +// The idiomatic handler an embedder wants to be able to offer its users. +async function slowTurn(): Promise { + const value = await new Promise((resolve) => { + // The host stores the resolve and calls it from its own callback — + // exactly how a shell-owned timer or file read reports completion. + nativeHostResolve(resolve); + }); + awaited = `awaited ${value}`; +} + +const handler = (turn: number): void => { + if (turn === 1) { + Promise.resolve().then(() => { + flag = "MICROTASK RAN"; + }); + void slowTurn(); + } + log.push(`turn ${turn}: flag=${flag} ${awaited}`); +}; + +nativeHostRegister(handler); +const turns = nativeHostRun(); +console.log(log.join("\n")); +console.log(`run returned ${turns}`); +console.log(`after run: flag=${flag} ${awaited}`); diff --git a/tests/ffi-drain/native.c b/tests/ffi-drain/native.c new file mode 100644 index 000000000..cc7c64f15 --- /dev/null +++ b/tests/ffi-drain/native.c @@ -0,0 +1,47 @@ +/* The host half of the drain fixture: native code that owns the main + * thread and re-enters the compiled program through a retained callback. + * + * hd_run() IS the host's loop. scriptc's own loop is not running while it + * executes — the program is parked inside this call — so the promise jobs + * the callbacks queue would otherwise sit until the program's main body + * returns. scriptc_drain() is the point at which this host says "now run + * what is ready", and it is an ordinary C call that returns. */ +#include +#include + +/* The runtime's host entry (scr_runtime.h declares it for in-tree code; + * an embedder declares it exactly like this). */ +extern bool scriptc_drain(void); + +typedef void (*hd_turn_cb)(double turn); +typedef void (*hd_resolve_cb)(double value); + +static hd_turn_cb hd_handler; +static hd_resolve_cb hd_resolve; + +void hd_register(hd_turn_cb handler) { hd_handler = handler; } + +/* The program hands the host a resolve function; the host keeps it and + * calls it from its own callback below. */ +void hd_take_resolve(hd_resolve_cb resolve) { hd_resolve = resolve; } + +double hd_run(void) { + /* Turn 1 schedules a continuation and starts an async handler. */ + hd_handler(1); + /* Turn 2 WITHOUT a drain in between: the continuation must still be + * queued, because nothing has told the program it may run. */ + hd_handler(2); + scriptc_drain(); + /* Turn 3 sees the bare microtask run — and only that one: the awaiting + * handler is parked on a promise the host has not settled yet. */ + hd_handler(3); + /* Now settle it from the host's own callback, exactly as a shell-owned + * timer would, and drain again. */ + if (hd_resolve != NULL) hd_resolve(7); + hd_handler(4); /* still parked: resolving queues, it does not resume */ + scriptc_drain(); + hd_handler(5); + /* A drain with nothing queued is a no-op that returns. */ + scriptc_drain(); + return 5; +} diff --git a/tests/ffi-drain/profile.json b/tests/ffi-drain/profile.json new file mode 100644 index 000000000..c3a310b2b --- /dev/null +++ b/tests/ffi-drain/profile.json @@ -0,0 +1,38 @@ +{ + "ffi_format": 4, + "functions": [ + { + "name": "nativeHostRegister", + "symbol": "hd_register", + "params": [ + { + "callback": { + "id": "turn", + "params": ["f64"], + "returns": "void", + "lifetime": "retained" + } + } + ], + "returns": "void" + }, + { + "name": "nativeHostResolve", + "symbol": "hd_take_resolve", + "params": [ + { + "callback": { + "id": "resolve", + "params": ["f64"], + "returns": "void", + "lifetime": "retained" + } + } + ], + "returns": "void" + }, + { "name": "nativeHostRun", "symbol": "hd_run", "params": [], "returns": "f64" } + ], + "libraries": [], + "system_libraries": [] +} diff --git a/tests/harness/ffi-drain.test.ts b/tests/harness/ffi-drain.test.ts new file mode 100644 index 000000000..2594c93f6 --- /dev/null +++ b/tests/harness/ffi-drain.test.ts @@ -0,0 +1,150 @@ +/* The executable lane's host-callable job checkpoint (`scriptc_drain`). + * + * Like the rest of the outbound-FFI surface this is an integration lane + * rather than a corpus case: Node has no host that owns the main thread + * and re-enters the program through a static-linked callback, so there is + * nothing to differential-run against. The same TypeScript and native + * archive run through BOTH backends and the transcript must match. + * + * The seam under test is the one an embedder actually hits: while the + * program is parked inside an outbound call, scriptc's loop is not + * running, so a continuation queued during one re-entry stays queued. + * The host's drain is the only thing that can let it run — and it must + * run it and RETURN, without a clock, a poll, or a turn. */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const repoRoot = join(import.meta.dirname, "../.."); +const fixtureRoot = join(repoRoot, "tests/ffi-drain"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +const flavor = sanitize ? "san" : "plain"; +const cacheRoot = join(repoRoot, "node_modules/.cache/scriptc-tests/ffi-drain", flavor); + +function nativeArchive(): string { + const outDir = join(cacheRoot, "native"); + mkdirSync(outDir, { recursive: true }); + const object = join(outDir, "native.o"); + const archive = join(outDir, "libhostdrain.a"); + execFileSync("clang", [ + "-std=c11", + ...(sanitize ? ["-O1", "-fsanitize=address"] : ["-O2"]), + "-c", + join(fixtureRoot, "native.c"), + "-o", + object, + ]); + execFileSync("ar", ["rcs", archive, object]); + return archive; +} + +/** The fixture manifest with the freshly built archive patched in — the + * ffi.test.ts recipe, so the checked-in profile stays path-free. */ +function manifest(archive: string): string { + const outDir = join(cacheRoot, "manifest"); + mkdirSync(outDir, { recursive: true }); + const profile = JSON.parse(readFileSync(join(fixtureRoot, "profile.json"), "utf8")) as { + libraries: string[]; + }; + profile.libraries = [archive]; + const path = join(outDir, "profile.json"); + writeFileSync(path, JSON.stringify(profile, null, 2)); + return path; +} + +/* Turn by turn: + * 1 schedules a bare continuation and starts an async handler that + * parks on a host-settled promise — nothing has run + * 2 a second re-entry with NO drain between: still nothing has run, + * which is the reported bug's exact shape + * 3 after the drain: the bare continuation ran; the awaiting handler + * is still parked, because the host has not settled its promise + * 4 the host settled it from its own callback — settling QUEUES the + * continuation, it does not resume the fiber inline + * 5 after the second drain: the await completed */ +const expected = [ + "turn 1: flag=not-run not-resolved", + "turn 2: flag=not-run not-resolved", + "turn 3: flag=MICROTASK RAN not-resolved", + "turn 4: flag=MICROTASK RAN not-resolved", + "turn 5: flag=MICROTASK RAN awaited 7", + "run returned 5", + "after run: flag=MICROTASK RAN awaited 7", + "", +].join("\n"); + +describe.each(["c", "llvm"] as const)("host job checkpoint, %s backend", (backend) => { + test("a host that owns the thread can run pending continuations between re-entries", async () => { + const outDir = join(cacheRoot, backend); + mkdirSync(outDir, { recursive: true }); + const result = await compile(join(fixtureRoot, "main.ts"), { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: manifest(nativeArchive()), + }); + if (!result.ok) { + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + } + expect(execFileSync(result.binaryPath, [], { encoding: "utf8" })).toBe(expected); + }); + + test("draining with an async frame suspended beneath the native call restores it", async () => { + // The outbound call is made from inside an async function, so a FIBER + // is on the stack under the host when it drains — and what the drain + // resumes is another fiber, which re-points the current fiber, the + // exception cell, and the ALS context at the job and then at MAIN. + // The suspended frame has to come back owning its own three, or its + // next `await` parks nothing and the program dies on the runtime's + // "await outside an async function" invariant. + const outDir = join(cacheRoot, `async-caller-${backend}`); + mkdirSync(outDir, { recursive: true }); + const entry = join(outDir, "main.ts"); + writeFileSync( + entry, + [ + "declare function nativeHostRegister(handler: (turn: number) => void): void;", + "declare function nativeHostRun(): number;", + "declare function nativeHostResolve(resolve: (value: number) => void): void;", + "let flag = \"not-run\";", + "let resolveIt: ((value: number) => void) | null = null;", + "async function parked(): Promise {", + " const v = await new Promise((resolve) => { resolveIt = resolve; });", + " flag = `ran${v}`;", + "}", + "nativeHostRegister((turn: number): void => {", + " if (turn === 1) {", + " void parked();", + " const r = resolveIt;", + " if (r !== null) { r(9); }", + " }", + "});", + "async function driver(): Promise {", + " const turns = nativeHostRun();", + " const tail = await Promise.resolve(\"tail\");", + " return `${turns} ${flag} ${tail}`;", + "}", + "// The manifest declares all three bindings; this program keeps the", + "// third one reachable without ever calling it.", + "const unusedResolve = (value: number): void => { flag = `${value}`; };", + "if (flag === \"impossible\") { nativeHostResolve(unusedResolve); }", + "void driver().then((s) => console.log(s));", + "", + ].join("\n"), + ); + const result = await compile(entry, { + outDir, + outPath: join(outDir, "program"), + backend, + sanitize, + ffiProfilePath: manifest(nativeArchive()), + }); + if (!result.ok) { + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + } + expect(execFileSync(result.binaryPath, [], { encoding: "utf8" })).toBe("5 ran9 tail\n"); + }); +}); diff --git a/tests/harness/library-asyncfree.test.ts b/tests/harness/library-asyncfree.test.ts index bfe4d9f54..da080c391 100644 --- a/tests/harness/library-asyncfree.test.ts +++ b/tests/harness/library-asyncfree.test.ts @@ -1,9 +1,18 @@ -/* The async_free gate (stage 2 of the library emission mode): v1 library mode - * refuses any async/timer/event-loop/ambient-process surface anywhere in +/* The event-loop gate (stage 2 of the library emission mode): v1 library + * mode refuses any timer/event-loop/ambient-process surface anywhere in * the module graph — SC4005's detector, module-graph-derived, never * runtime-observed. Programs here compile fine as EXECUTABLES (the exe * lane keeps its loop); what is asserted is the IR-level detection the - * library path refuses on. */ + * library path refuses on. + * + * Promises, `await`, and `async` functions are ADMITTED: a continuation + * is queued work rather than a turn, and the host runs the queue itself + * through the profile's drain entry. The gate's job is to keep the line + * exactly there — everything a loop turn would have to SERVICE stays + * refused, and the two async shapes with no host-drain story (a top-level + * await, and generators) stay refused with it. `moduleUsesAsync` is the + * complementary fact: not "is this refused" but "does this graph need the + * promise/fiber unit linked". */ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -16,7 +25,7 @@ const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests"); * builds, so they must never share build dirs. */ const flavor = process.env["SCRIPTC_SAN"] === "1" ? "san" : "plain"; -async function surfaceOf(name: string, source: string): Promise { +async function moduleOf(name: string, source: string): Promise { const outDir = join(cacheDir, `lib-asyncfree-${flavor}`, name); mkdirSync(outDir, { recursive: true }); const entry = join(outDir, "main.ts"); @@ -30,8 +39,11 @@ async function surfaceOf(name: string, source: string): Promise { if (!result.ok) { throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); } - const mod = JSON.parse(readFileSync(result.irPath!, "utf8")) as ir.IrModule; - const hit = ir.moduleLibAsyncSurface(mod); + return JSON.parse(readFileSync(result.irPath!, "utf8")) as ir.IrModule; +} + +async function surfaceOf(name: string, source: string): Promise { + const hit = ir.moduleLibAsyncSurface(await moduleOf(name, source)); if (hit !== null) { // The anchor must be usable (a real file offset or the entry fallback). expect(hit.loc.file.length).toBeGreaterThan(0); @@ -39,7 +51,7 @@ async function surfaceOf(name: string, source: string): Promise { return hit === null ? null : hit.surface; } -describe("async_free detection over the IR", () => { +describe("the library event-loop gate over the IR", () => { test("a sync graph is async_free", async () => { expect( await surfaceOf( @@ -49,13 +61,49 @@ describe("async_free detection over the IR", () => { ).toBeNull(); }); - test("an async function anywhere refuses, named", async () => { + test("an async function is admitted — its continuation is the host's to drain", async () => { expect( await surfaceOf( "async-fn", `async function tick(): Promise { return 1; }\nvoid tick();\nconsole.log("x");\n`, ), - ).toContain("async function ('tick')"); + ).toBeNull(); + }); + + test("await and promise values are admitted", async () => { + expect( + await surfaceOf( + "await-promise", + `async function twice(p: Promise): Promise { return (await p) * 2; }\n` + + `void twice(Promise.resolve(21));\nconsole.log("x");\n`, + ), + ).toBeNull(); + }); + + test("queueMicrotask is admitted — it is the job queue, not a timer", async () => { + expect( + await surfaceOf("micro", `queueMicrotask(() => console.log("m"));\n`), + ).toBeNull(); + }); + + test("a top-level await refuses: library init has nothing to wait on", async () => { + expect( + await surfaceOf( + "top-level-await", + `const v = await Promise.resolve(1);\nconsole.log(v);\n`, + ), + ).toContain("top-level await"); + }); + + test("fs.promises stays refused even though its promises settle eagerly", async () => { + expect( + await surfaceOf( + "fsp", + `import { readFile } from "node:fs/promises";\n` + + `async function read(): Promise { return await readFile("x", "utf8"); }\n` + + `void read();\nconsole.log("x");\n`, + ), + ).toContain("fs.promises"); }); test("a generator refuses", async () => { @@ -95,7 +143,7 @@ describe("async_free detection over the IR", () => { ).toContain("process.stdout.write completion callbacks"); }); - test("explicitly omitted process output callbacks remain async_free", async () => { + test("explicitly omitted process output callbacks stay admitted", async () => { expect( await surfaceOf( "stdout-write-undefined", @@ -104,3 +152,24 @@ describe("async_free detection over the IR", () => { ).toBeNull(); }); }); + +describe("the promise/fiber link gate over the IR", () => { + test("a graph with no continuation needs no promise unit", async () => { + expect( + ir.moduleUsesAsync( + await moduleOf("gate-clean", `export function twice(n: number): number { return n * 2; }\nconsole.log(twice(21));\n`), + ), + ).toBe(false); + }); + + test("an async function, an await, a promise value, and queueMicrotask each need it", async () => { + for (const [name, source] of [ + ["gate-async", `async function t(): Promise { return 1; }\nvoid t();\nconsole.log("x");\n`], + ["gate-promise", `const p = Promise.resolve(1);\nvoid p.then((v) => console.log(v));\n`], + ["gate-micro", `queueMicrotask(() => console.log("m"));\n`], + ["gate-tick", `process.nextTick(() => console.log("t"));\n`], + ] as const) { + expect(ir.moduleUsesAsync(await moduleOf(name, source)), name).toBe(true); + } + }); +}); diff --git a/tests/harness/library-cross.test.ts b/tests/harness/library-cross.test.ts index 7cdb8a3d1..31deddf06 100644 --- a/tests/harness/library-cross.test.ts +++ b/tests/harness/library-cross.test.ts @@ -269,9 +269,9 @@ describe.skipIf(!enabled)("cross-target library conformance", () => { expect(undef.has(spelling), `undefined reference to ${spelling}`).toBe(false); } } - // The structural async_free consequence: no fiber/event-loop/ - // timer/child symbol defined in the archive, none referenced by - // a linked unit. + // These fixtures reach no continuation, so the promise/fiber + // unit is not gated in: no fiber/event-loop/timer/child symbol + // defined in the archive, none referenced by a linked unit. expect([...defined].filter((s) => LOOPISH.test(s))).toEqual([]); expect([...undef].filter((s) => LOOPISH.test(s))).toEqual([]); diff --git a/tests/harness/library-mode.test.ts b/tests/harness/library-mode.test.ts index 5acd7f931..264b77618 100644 --- a/tests/harness/library-mode.test.ts +++ b/tests/harness/library-mode.test.ts @@ -56,7 +56,16 @@ * judged by the same bar; every ineligible one * refuses SC4020 naming the failed bar — never * a generic SC1010/SC2013 — and builtins keep - * the SC4005 async_free story + * the SC4005 event-loop story + * K15 async-drain async functions in the graph plus the + * profile-declared job checkpoint: a scheduled + * continuation runs at the DRAIN and nowhere + * else, settling a promise from the host queues + * rather than resumes, a quiescent drain is a + * no-op — and the archive that carries all of + * that still defines no loop entry, references + * no poll/sleep/child primitive, and keeps the + * K8 ambient audit green * K14 determinism-fences the ask-5 deny-by-manifest-id surface: a * reached fenced static surface refuses SC4008 * (id + name + attributed teaching note, both @@ -214,16 +223,96 @@ describe.each(EMISSIONS)("library mode, %s emission", (emission) => { for (const banned of ["sigaction", "signal", "pthread_create", "atexit", "setvbuf"]) { expect(undef.has(banned), `undefined reference to ${banned}`).toBe(false); } - // The structural async_free consequence: no fiber/event-loop/timer/ - // child-process symbol is DEFINED in the archive (scr_async.c and - // scr_child.c never joined the link) and none is REFERENCED by a - // linked unit (a missed inter-unit reference would surface here, the - // backstop the design asks for instead of a hand-maintained map). + // This fixture's graph reaches no continuation, so the promise/fiber + // unit is not gated in either: no fiber/event-loop/timer/child-process + // symbol is DEFINED in the archive (scr_async.c and scr_child.c never + // joined the link) and none is REFERENCED by a linked unit (a missed + // inter-unit reference would surface here, the backstop the design + // asks for instead of a hand-maintained map). K15 covers the archive + // that DOES link the promise unit — still without a loop. const loopish = /^scr_(loop|fiber|on_fiber|timer|set_timeout|set_interval|set_immediate|next_tick|child|spawn)/; expect([...defined].filter((s) => loopish.test(s))).toEqual([]); expect([...undef].filter((s) => loopish.test(s))).toEqual([]); }); + /* ── K15: async in the graph + the host's job checkpoint ─────────────── */ + + const ASYNC_EXPECTED = `lib: async library ready +lib: scheduled +host: schedule -> 0 +host: still 0 before any drain +lib: microtask m +host: after drain 0 +lib: enter job +lib: parked a +host: start -> 0 +host: settle -> 0 +lib: job resumed X +lib: parked b +host: settle -> 0 +lib: job done XY +host: done -> 1 +host: quiescent drain returned +`; + + test("K15: continuations run at the drain, and only there", async () => { + const { archive, outDir } = await buildLibrary("async", emission); + const probe = buildProbe("async", archive, outDir); + const run = runProbe(probe); + expect(run.signal).toBeNull(); + expect(run.status).toBe(0); + expect(run.stdout).toBe(ASYNC_EXPECTED); + }); + + test("K15/K8: the async archive still links no loop and stays ambient-clean", async () => { + const { archive } = await buildLibrary("async", emission); + const { defined, undef } = nmSymbols(archive); + + // The declared ABI, both directions — K1 over a drain-carrying profile. + expect([...defined].filter((s) => s.startsWith("ka_")).sort()).toEqual( + [ + "ka_init", "ka_set_panic_sink", "ka_collect", "ka_drain", "ka_set_callback", + "ka_start", "ka_schedule", "ka_settle", "ka_done", + ].sort(), + ); + expect([...undef].filter((s) => s.startsWith("ka_"))).toEqual([]); + + // K8 unchanged: no process disposition, no threads, no atexit. + for (const banned of ["sigaction", "signal", "pthread_create", "atexit", "setvbuf"]) { + expect(undef.has(banned), `undefined reference to ${banned}`).toBe(false); + } + + // The promise/fiber unit joined the link — that is what async needs — + // but the LOOP did not come with it, and neither did the child-process + // hooks or the sleep/poll primitives a turn would use. This is the + // mechanical form of "a library artifact links no event loop". + expect(defined.has("scr_drain_jobs")).toBe(true); + expect(defined.has("scr_promise_new")).toBe(true); + // K8's loopish regex, re-applied to the archive that DOES carry the + // promise unit: fibers and the job queues are here, and the loop's own + // machinery is not — the `#ifndef SCR_LIB` fences in scr_async.c. + for (const loopish of [ + "scr_loop_run", "scr_loop_has_ready", "scr_loop_set_io", "scr_loop_set_events", + "scr_now_ms", "scr_set_timeout", "scr_set_interval", "scr_set_immediate", + "scr_clear_interval", "scr_timers_teardown", "scr_active_resources", + "scr_report_unhandled_rejections", "scr_fsp_read_file", "scr_tp_set_timeout", + ]) { + expect(defined.has(loopish), `archive defines ${loopish}`).toBe(false); + expect(undef.has(loopish), `archive references ${loopish}`).toBe(false); + } + for (const absent of ["scr_children_pending", "scr_children_wait", "scr_children_teardown"]) { + expect(undef.has(absent), `undefined reference to ${absent}`).toBe(false); + } + // poll(2) is the loop's idle sleep and its only entry into waiting; + // nothing else in the library link set reaches for it. (nanosleep is + // deliberately NOT asserted here: scr_lib.c has referenced it since + // before this unit joined any library link.) + expect(undef.has("poll"), "undefined reference to poll").toBe(false); + // The executable lane's unprefixed host entry is exe-only: a library + // artifact keeps the profile's symbol space to itself. + expect(defined.has("scriptc_drain")).toBe(false); + }); + /* ── K12: the npm posture's compile side ─────────────────────────────── */ test("K12: an eligible npm package (and its own dep) compiles statically", async () => { @@ -234,8 +323,8 @@ describe.each(EMISSIONS)("library mode, %s emission", (emission) => { expect(run.status).toBe(0); // scaled: mathkit.scale (which itself calls mathdep.twice) + OFFSET — // values computed by the packages' shipped JS, statically compiled; - // tail: the node:path builtin riding the same graph (async_free, so - // SC4005 has nothing to say). + // tail: the node:path builtin riding the same graph (it reaches no + // event-loop surface, so SC4005 has nothing to say). expect(run.stdout).toBe( `npm-static ready scaled: 37 diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index 86509fe9e..44b6662db 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -60,6 +60,9 @@ describe("library profile validation", () => { expect(r.profile.initSymbol).toBe("kx_init"); expect(r.profile.collectSymbol).toBe("kx_collect"); expect(r.profile.resultResetSymbol).toBeNull(); + // The job checkpoint is opt-in: absent means the artifact a pre-drain + // profile always produced. + expect(r.profile.drainSymbol).toBeNull(); // entry resolves against the profile file's directory expect(r.profile.entry).toBe(join(dir, "src/lib.ts")); expect(r.profile.exports).toHaveLength(2); @@ -184,6 +187,18 @@ describe("library profile validation", () => { const r = loadLibraryProfile(writeProfile({ ...good, determinism: { deny: ["Math.random"] } })); expect(r.ok).toBe(true); }); + test("a declared drain symbol resolves", () => { + const r = loadLibraryProfile( + writeProfile({ ...good, abi: { ...good.abi, drain_symbol: "kx_drain" } }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.profile.drainSymbol).toBe("kx_drain"); + }); + test("drain symbol without the prefix", () => + expectSc4001({ ...good, abi: { ...good.abi, drain_symbol: "other_drain" } }, "prefix")); + test("drain symbol colliding with another entry", () => + expectSc4001({ ...good, abi: { ...good.abi, drain_symbol: "kx_collect" } }, "declared twice")); test("duplicate symbols", () => expectSc4001( { ...good, exports: [{ export: "update", symbol: "kx_init", params: [], returns: "void" }] }, diff --git a/tests/library-mode/async/lib.ts b/tests/library-mode/async/lib.ts new file mode 100644 index 000000000..4252d0f43 --- /dev/null +++ b/tests/library-mode/async/lib.ts @@ -0,0 +1,64 @@ +// K15 fixture: async functions in the library graph, and the host-driven +// job checkpoint that lets their continuations run. +// +// The host owns time. Nothing here reads a clock, arms a timer, or asks +// for a loop: `awaitHost` hands the pending resolve to module state and +// parks, the HOST decides when to settle it (`settle`), and the profile's +// drain entry decides when the continuation actually runs. Every line the +// library emits goes out through the `emit` channel, so the probe can +// assert exactly what had happened at each point. +declare function emit(line: string): void; + +let pending: ((value: string) => void) | null = null; +let finished = 0; + +// The host-owned await: park on a promise only `settle` can resolve. +function awaitHost(tag: string): Promise { + return new Promise((resolve) => { + pending = resolve; + emit(`parked ${tag}`); + }); +} + +async function handle(label: string): Promise { + emit(`enter ${label}`); + const a = await awaitHost("a"); + emit(`${label} resumed ${a}`); + const b = await awaitHost("b"); + emit(`${label} done ${a}${b}`); + finished = finished + 1; +} + +// Starts an async handler. Its SYNCHRONOUS prefix runs here (JS's rule), +// so the return value must still see finished == 0. +export function start(label: string): number { + void handle(label); + return finished; +} + +// The narrowest half of the report: a bare `Promise.resolve().then()`. +// Queued here, run at the next drain — never at some later program exit. +export function schedule(): number { + Promise.resolve("m").then((v) => { + emit(`microtask ${v}`); + }); + emit("scheduled"); + return finished; +} + +// The host settles the parked promise. Settling QUEUES the continuation; +// it does not run it — that is the drain's job, and the return value +// proves the difference. +export function settle(value: string): number { + const resolve = pending; + if (resolve === null) return -1; + pending = null; + resolve(value); + return finished; +} + +export function done(): number { + return finished; +} + +emit("async library ready"); diff --git a/tests/library-mode/async/probe.c b/tests/library-mode/async/probe.c new file mode 100644 index 000000000..8c2374f5e --- /dev/null +++ b/tests/library-mode/async/probe.c @@ -0,0 +1,66 @@ +/* K15 async-drain probe. The library's every emission is recorded through + * the `emit` channel and printed with a "lib:" tag; the host's own + * observations print with "host:". What the transcript has to show: + * + * - a scheduled continuation does NOT run when it is scheduled, and does + * not run when the entry that scheduled it returns; + * - it runs at the drain, and only there; + * - settling a promise from the host QUEUES the parked fiber's + * continuation rather than resuming it inline (the settle entry still + * observes the pre-continuation state); + * - the drain returns after the queues are empty, so the host keeps the + * thread and the loop is never involved. + * + * Without the drain entry there is nothing to call and the last four + * "lib:" lines never appear at all — which is the bug this fixture pins. + */ +#include +#include + +extern void ka_init(void); +extern void ka_set_panic_sink(void (*fn)(void *, const uint8_t *, size_t, uint64_t), void *ctx); +extern void ka_collect(void); +extern void ka_drain(void); +extern int32_t ka_set_callback(const char *name, void (*fn)(void), void *ctx); +extern double ka_start(const uint8_t *p, size_t len); +extern double ka_schedule(void); +extern double ka_settle(const uint8_t *p, size_t len); +extern double ka_done(void); + +static void sink(void *ctx, const uint8_t *msg, size_t len, uint64_t addr) { + (void)ctx; (void)addr; + printf("UNEXPECTED SINK: %.*s", (int)len, (const char *)msg); +} + +static void on_emit(void *ctx, const uint8_t *p, size_t len) { + (void)ctx; + printf("lib: %.*s\n", (int)len, (const char *)p); +} + +#define LIT(s) (const uint8_t *)(s), sizeof(s) - 1 + +int main(void) { + ka_set_panic_sink(sink, NULL); + ka_set_callback("emit", (void (*)(void))on_emit, NULL); + ka_init(); + + /* A bare promise continuation: queued, not run. */ + printf("host: schedule -> %.0f\n", ka_schedule()); + printf("host: still %.0f before any drain\n", ka_done()); + ka_drain(); + printf("host: after drain %.0f\n", ka_done()); + + /* An async handler that awaits twice on host-settled promises. */ + printf("host: start -> %.0f\n", ka_start(LIT("job"))); + printf("host: settle -> %.0f\n", ka_settle(LIT("X"))); + ka_drain(); + printf("host: settle -> %.0f\n", ka_settle(LIT("Y"))); + ka_drain(); + printf("host: done -> %.0f\n", ka_done()); + + /* Quiescent: a drain with nothing queued is a no-op that returns. */ + ka_drain(); + printf("host: quiescent drain returned\n"); + ka_collect(); + return 0; +} diff --git a/tests/library-mode/async/profile.json b/tests/library-mode/async/profile.json new file mode 100644 index 000000000..75e8748e9 --- /dev/null +++ b/tests/library-mode/async/profile.json @@ -0,0 +1,24 @@ +{ + "profile_format": 1, + "name": "conformance-async", + "entry": "lib.ts", + "emission": "llvm", + "abi": { + "prefix": "ka_", + "init_symbol": "ka_init", + "sink_register_symbol": "ka_set_panic_sink", + "collect_symbol": "ka_collect", + "result_reset_symbol": null, + "drain_symbol": "ka_drain", + "callback_register_symbol": "ka_set_callback" + }, + "callbacks": [ + { "name": "emit", "params": ["string"], "returns": "void" } + ], + "exports": [ + { "export": "start", "symbol": "ka_start", "params": ["string"], "returns": "f64" }, + { "export": "schedule", "symbol": "ka_schedule", "params": [], "returns": "f64" }, + { "export": "settle", "symbol": "ka_settle", "params": ["string"], "returns": "f64" }, + { "export": "done", "symbol": "ka_done", "params": [], "returns": "f64" } + ] +}