diff --git a/docs/src/app/cli/page.mdx b/docs/src/app/cli/page.mdx
index 0d0d85842..52cc84845 100644
--- a/docs/src/app/cli/page.mdx
+++ b/docs/src/app/cli/page.mdx
@@ -63,7 +63,7 @@ Analyzes the program without producing a binary and reports, statement by statem
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
--backend <c|llvm>
- Code generator: llvm (default — emits LLVM IR text, compiled by the same clang) or c (the readable debugging backend). Unset, a native build can fall back to C when the program is outside the LLVM tier. The production wasm32-wasi target never falls back: a missing LLVM lowering is SC3001. Use --backend c explicitly only when inspecting generated C.
+ Code generator: llvm (default — emits LLVM IR text, compiled by the same clang) or c (the readable debugging backend). Unset, a native build can fall back to C when the program is outside the LLVM tier. The production wasm32-wasi target never falls back: a missing LLVM lowering is SC3001. Use --backend c explicitly only when inspecting generated C; on WASI that inspection lane accepts async-free programs only and reports SC3001 for coroutine-dependent surfaces.
--npm-static <pkg[,pkg…]|auto>
EXPERIMENTAL. Compile the named npm packages' shipped JS statically as program modules instead of embedding them for the engine (repeatable; auto opts in every eligible direct import). A package the preflight refuses falls back to the island with a coverage-report note. See npm Dependencies for maturity notes.
diff --git a/docs/src/app/platforms/page.mdx b/docs/src/app/platforms/page.mdx
index 054c41b83..560787b2d 100644
--- a/docs/src/app/platforms/page.mdx
+++ b/docs/src/app/platforms/page.mdx
@@ -37,9 +37,9 @@ The full corpus runs in a differential lane against Windows Node, including `--d
### WebAssembly (WASI Preview 1)
-Set SCRIPTC_CC=zigcc and SCRIPTC_TARGET=wasm32-wasi to produce a standalone .wasm module. Without -o, scriptc build hello.ts writes .scriptc/hello.wasm. scriptc run hosts the module with Node's WASI implementation, inherits stdio and environment, preopens the current working directory as /, and maps the host's /tmp to the guest's /tmp. A built module can run in another WASI Preview 1 host instead.
+Set SCRIPTC_CC=zigcc and SCRIPTC_TARGET=wasm32-wasi to produce a standalone .wasm module. Without -o, scriptc build hello.ts writes .scriptc/hello.wasm. scriptc run hosts the module with Node's WASI implementation, inherits stdio and environment, preopens the current working directory as /, and maps the host platform's temporary directory to the guest's /tmp. A built module can run in another WASI Preview 1 host instead.
-WASI is a production LLVM target with the same language tiers as the native targets. Its 32-bit LLVM ABI supports collections, closures, exceptions, classes, checked dynamic values, async/await, promises, synchronous and asynchronous generators, timers, stdin/readline events, process-exit listeners, filesystem callbacks and promises, and the --dynamic QuickJS island. The debugging C backend remains available only through an explicit --backend c; WASI never silently falls back to it.
+WASI is a production LLVM target with the same language tiers as the native targets. Its 32-bit LLVM ABI supports collections, closures, exceptions, classes, checked dynamic values, async/await, promises, synchronous and asynchronous generators, timers, stdin/readline events, process-exit listeners, filesystem callbacks and promises, and the --dynamic QuickJS island. The debugging C backend remains available for async-free inspection only through an explicit --backend c; coroutine-dependent programs report SC3001, and WASI never silently falls back to C.
The remaining boundary is host capability, not language coverage. WASI Preview 1 has no portable socket, process-spawn, OS-signal, network-interface, or filesystem-notification APIs. Networking/fetch, child processes, signal APIs, os.networkInterfaces(), and fs.watch therefore fail before linking with diagnostic SC3002. --sanitize and native FFI are unavailable too. Filesystem behavior is bounded by the host's preopens, and process/OS introspection follows WASI's reduced model.
diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts
index b652922e4..619bfb6ea 100644
--- a/packages/cli/src/main.ts
+++ b/packages/cli/src/main.ts
@@ -32,7 +32,8 @@ Options:
builds — the default lane emits C for it and a one-line
stderr note names the construct — while an explicit
--backend llvm fails with that construct named
- wasm32-wasi is LLVM-only unless --backend c is explicit
+ wasm32-wasi is LLVM-only unless --backend c is explicit;
+ its C inspection lane accepts async-free programs only
--from-c treat input as a C (or .ll) file (toolchain plumbing/debugging)
--keep-c keep the generated program TU next to the executable
(default; the .ll — or the .c under --backend=c or
diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts
index d15dff59c..d8a71d8df 100644
--- a/packages/cli/src/paths.ts
+++ b/packages/cli/src/paths.ts
@@ -1,3 +1,4 @@
+import { tmpdir } from "node:os";
import { buildTargetPlatform } from "@scriptc/compiler";
/** Default executable filename for the build target. Explicit --out paths
@@ -7,3 +8,13 @@ export function defaultExecutableName(stem: string, platform: string = buildTarg
if (platform === "wasi") return `${stem}.wasm`;
return stem;
}
+
+/** Host paths exposed by `scriptc run` to a WASI Preview 1 module. Guest
+ * `/tmp` maps to the host's real platform temp directory instead of assuming
+ * the POSIX spelling exists (notably false on Windows). */
+export function wasiPreopens(
+ cwd: string = process.cwd(),
+ hostTmp: string = tmpdir(),
+): Record {
+ return { "/": cwd, "/tmp": hostTmp };
+}
diff --git a/packages/cli/src/wasi-runner.ts b/packages/cli/src/wasi-runner.ts
index ee4baa870..c8cb148de 100644
--- a/packages/cli/src/wasi-runner.ts
+++ b/packages/cli/src/wasi-runner.ts
@@ -5,6 +5,7 @@
*/
import { readFile } from "node:fs/promises";
import { WASI } from "node:wasi";
+import { wasiPreopens } from "./paths.js";
type WasiInstance = Parameters[0];
declare const WebAssembly: {
@@ -22,8 +23,9 @@ const wasi = new WASI({
args: [binary],
env: process.env,
// A native scriptc executable inherits access to the caller's filesystem.
- // WASI is capability-based, so expose the caller's working tree as `/`.
- preopens: { "/": process.cwd(), "/tmp": "/tmp" },
+ // WASI is capability-based, so expose the caller's working tree as `/`
+ // and the platform's actual temporary directory as guest `/tmp`.
+ preopens: wasiPreopens(),
returnOnExit: true,
});
const instantiated = await WebAssembly.instantiate(
diff --git a/packages/cli/test/paths.test.ts b/packages/cli/test/paths.test.ts
index 9fab1c7fb..4b54508d5 100644
--- a/packages/cli/test/paths.test.ts
+++ b/packages/cli/test/paths.test.ts
@@ -1,6 +1,6 @@
import { buildTargetPlatform } from "@scriptc/compiler";
import { expect, test } from "vitest";
-import { defaultExecutableName } from "../src/paths.js";
+import { defaultExecutableName, wasiPreopens } from "../src/paths.js";
test("default executable names use the Windows PE suffix", () => {
expect(defaultExecutableName("main", "win32")).toBe("main.exe");
@@ -18,6 +18,13 @@ test("WASI cross-builds use the WebAssembly suffix", () => {
expect(defaultExecutableName("main", platform)).toBe("main.wasm");
});
+test("WASI preopens map guest /tmp to the host platform temp directory", () => {
+ expect(wasiPreopens("C:\\work\\repo", "C:\\Users\\runner\\AppData\\Local\\Temp")).toEqual({
+ "/": "C:\\work\\repo",
+ "/tmp": "C:\\Users\\runner\\AppData\\Local\\Temp",
+ });
+});
+
test("Windows cross-builds use the PE suffix on a non-Windows host", () => {
const platform = buildTargetPlatform({
SCRIPTC_CC: "zigcc",
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index baab005cc..9a40c56e2 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -8687,8 +8687,8 @@ class LlEmitter {
* POINTER-SIZE slots off the emit tuple's va_list (textual LLVM IR
* cannot va_arg portably) and calls this function behind the wrapper
* closure's fn. It re-types each slot to the listener's declared
- * parameter (f64 from its i64 bit pattern, bool from the zero-extended
- * slot, refcounted values retained — the callee owns +1 per the
+ * parameter (f64/bool scalars load from the emit site's stack slot,
+ * refcounted values retain the direct pointer — the callee owns +1 per the
* universal convention) and calls the ORIGINAL listener held by the
* wrapper's one capture box. A non-void result is discarded (refcounted
* ones released) — Node ignores listener return values. Interned per
@@ -8720,16 +8720,10 @@ class LlEmitter {
cbT.params.forEach((p, i) => {
const ty = this.llType(p);
if (ty === "double") {
- d.push(
- ` %x${i} = ptrtoint ptr %a${i} to i64`,
- ` %d${i} = bitcast i64 %x${i} to double`,
- );
+ d.push(` %d${i} = load double, ptr %a${i}`);
passed.push(`double %d${i}`);
} else if (ty === "i1") {
- d.push(
- ` %x${i} = ptrtoint ptr %a${i} to i64`,
- ` %b${i} = trunc i64 %x${i} to i1`,
- );
+ d.push(` %b${i} = load i1, ptr %a${i}`);
passed.push(`i1 %b${i}`);
} else if (isRefCounted(p)) {
d.push(` %r${i} = call ptr ${retainSym(this, p)}(ptr %a${i})`);
@@ -12765,22 +12759,19 @@ class LlEmitter {
}
if (e.fn === "emitter.emit") {
// The variadic dispatch: the event's unified tuple rides the C
- // variadic tail POINTER-CLASSED (f64 as its i64 bit pattern, bool
- // zero-extended — scr_runtime.h's fixed-shim contract; the emitted
- // adapters re-type on the way out), every argument borrowed. May
+ // variadic tail POINTER-CLASSED. Scalar values ride pointers to
+ // call-lived stack slots, while reference values ride directly; the
+ // fixed shim can therefore read one pointer-width slot per argument
+ // on both wasm32 and native targets. Every argument is borrowed. May
// throw (listeners run inside).
const args = e.args.map((a) => this.emitExpr(a));
const tuple = args.slice(2).map((a) => {
const ty = this.llType(a.type);
- if (ty === "double") {
- const x = B.tmp();
- B.line(`${x} = bitcast double ${a.name} to i64`);
- return `i64 ${x}`;
- }
- if (ty === "i1") {
- const x = B.tmp();
- B.line(`${x} = zext i1 ${a.name} to i64`);
- return `i64 ${x}`;
+ if (ty === "double" || ty === "i1") {
+ const slot = B.slot();
+ B.entryAllocas.push(`${slot} = alloca ${ty} ; EventEmitter scalar argument`);
+ B.line(`store ${ty} ${a.name}, ptr ${slot}`);
+ return `ptr ${slot}`;
}
return `ptr ${a.name}`;
});
diff --git a/packages/compiler/src/frontend/npm.ts b/packages/compiler/src/frontend/npm.ts
index eb9a85247..c69da9181 100644
--- a/packages/compiler/src/frontend/npm.ts
+++ b/packages/compiler/src/frontend/npm.ts
@@ -97,6 +97,11 @@ export interface EmbeddedModule {
key: string;
source: string;
format: EmbeddedFormat;
+ /** The parsed source reaches Node's global fetch binding. This is a
+ * semantic capability fact, unlike the old whole-source word scan: comments,
+ * strings, property names, and locally-bound `fetch` identifiers do not set
+ * it. The IR/link gates consume this bit without reparsing embedded text. */
+ usesFetch?: true;
/** CommonJS only: the synthesized ESM facade the island's module loader
* evaluates when an ES module imports this file — default plus the LEXED
* named exports (cjsEsmFacadeSource). ESM sources compile natively and
@@ -234,6 +239,124 @@ export interface NpmRuntimeGraph {
errors: NpmGraphError[];
}
+/** Embedded modules whose executable syntax reads Node's global fetch.
+ *
+ * The npm graph already owns every source string, so bind them together in a
+ * no-lib TypeScript program once and ask the checker whether a `fetch`
+ * identifier resolves locally. With no ambient library, an unresolved read is
+ * exactly the engine global; declarations/imports/parameters retain symbols.
+ * `globalThis.fetch` and `global.fetch` are recognized explicitly when their
+ * receiver is likewise unshadowed. Parsing is recovery-capable, matching the
+ * module-specifier sweep's treatment of shipped JavaScript. */
+export function embeddedModulesUsingGlobalFetch(
+ modules: readonly EmbeddedModule[],
+): ReadonlySet {
+ const sourceFiles = new Map();
+ for (const mod of modules) {
+ if (mod.format === "json") continue;
+ sourceFiles.set(
+ mod.key,
+ ts.createSourceFile(mod.key, mod.source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS),
+ );
+ }
+ if (sourceFiles.size === 0) return new Set();
+
+ const options: ts.CompilerOptions = {
+ allowJs: true,
+ checkJs: false,
+ noLib: true,
+ target: ts.ScriptTarget.Latest,
+ module: ts.ModuleKind.ESNext,
+ moduleDetection: ts.ModuleDetectionKind.Force,
+ };
+ const canonical = (fileName: string): string => {
+ const normalized = fileName.replace(/\\/g, "/");
+ return ts.sys.useCaseSensitiveFileNames ? normalized : normalized.toLowerCase();
+ };
+ const byCanonical = new Map(
+ [...sourceFiles].map(([fileName, sourceFile]) => [canonical(fileName), sourceFile] as const),
+ );
+ const originalKeyByCanonical = new Map(
+ [...sourceFiles.keys()].map((fileName) => [canonical(fileName), fileName] as const),
+ );
+ const host: ts.CompilerHost = {
+ getSourceFile: (fileName) => byCanonical.get(canonical(fileName)),
+ getDefaultLibFileName: () => "",
+ writeFile: () => {},
+ getCurrentDirectory: () => "/",
+ getDirectories: () => [],
+ fileExists: (fileName) => byCanonical.has(canonical(fileName)),
+ readFile: (fileName) => byCanonical.get(canonical(fileName))?.text,
+ getCanonicalFileName: canonical,
+ useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames,
+ getNewLine: () => "\n",
+ directoryExists: () => true,
+ realpath: (fileName) => fileName,
+ };
+ const program = ts.createProgram({ rootNames: [...sourceFiles.keys()], options, host });
+ const checker = program.getTypeChecker();
+ const found = new Set();
+
+ const unboundGlobalObject = (node: ts.Expression): boolean => {
+ if (
+ !ts.isIdentifier(node) ||
+ (node.text !== "globalThis" && node.text !== "global")
+ ) {
+ return false;
+ }
+ const symbol = checker.getSymbolAtLocation(node);
+ // TypeScript synthesizes a declaration-less globalThis symbol even in a
+ // no-lib program. A source shadow (parameter/local/import) always carries
+ // at least one declaration; the Node `global` alias remains unresolved.
+ return (
+ symbol === undefined ||
+ symbol.declarations === undefined ||
+ symbol.declarations.length === 0
+ );
+ };
+
+ for (const sourceFile of program.getSourceFiles()) {
+ let usesFetch = false;
+ const visit = (node: ts.Node): void => {
+ if (usesFetch) return;
+ if (
+ ts.isPropertyAccessExpression(node) &&
+ node.name.text === "fetch" &&
+ unboundGlobalObject(node.expression)
+ ) {
+ usesFetch = true;
+ return;
+ }
+ if (
+ ts.isElementAccessExpression(node) &&
+ ts.isStringLiteralLike(node.argumentExpression) &&
+ node.argumentExpression.text === "fetch" &&
+ unboundGlobalObject(node.expression)
+ ) {
+ usesFetch = true;
+ return;
+ }
+ if (ts.isIdentifier(node) && node.text === "fetch") {
+ // A property-access name is not a binding read (`obj.fetch`); the
+ // global-object case was handled above. Every real bare read has no
+ // symbol in this no-lib program unless source syntax binds it.
+ if (
+ !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) &&
+ checker.getSymbolAtLocation(node) === undefined
+ ) {
+ usesFetch = true;
+ return;
+ }
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(sourceFile);
+ const originalKey = originalKeyByCanonical.get(canonical(sourceFile.fileName));
+ if (usesFetch && originalKey !== undefined) found.add(originalKey);
+ }
+ return found;
+}
+
/** Builtins the island provides shims for (scr_island.c). Both spellings
* resolve to the canonical "node:" key. Subpath forms ("fs/promises") are
* NOT shimmed unless listed here explicitly. */
@@ -958,6 +1081,10 @@ export class NpmGraphBuilder {
finish(): NpmRuntimeGraph {
this.synthesizeCjsFacades();
+ for (const key of embeddedModulesUsingGlobalFetch([...this.modules.values()])) {
+ const mod = this.modules.get(key);
+ if (mod !== undefined) mod.usesFetch = true;
+ }
return {
modules: [...this.modules.values()],
edges: this.edges,
diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts
index 6a300e381..83bb1f1c1 100644
--- a/packages/compiler/src/index.ts
+++ b/packages/compiler/src/index.ts
@@ -182,6 +182,22 @@ function llvmRefusalDiag(err: LlvmUnsupportedError, entryPath: string): ScrDiagn
};
}
+/** A valid IR surface the explicitly-selected code generator cannot host.
+ * SC3001 is backend coverage (as with an LLVM tier refusal), not a target
+ * capability gap: wasm32-wasi's production LLVM lane still accepts it. */
+function backendRefusalDiag(
+ backend: "c" | "llvm",
+ target: string,
+ surface: string,
+ loc: SrcLoc,
+): ScrDiagnostic {
+ return {
+ code: "SC3001",
+ message: `${backend} backend does not support ${surface} for ${target}; use --backend llvm`,
+ loc,
+ };
+}
+
/** A valid program surface that the selected execution target cannot host.
* SC3xxx stays the backend/target-coverage family: source semantics are
* valid, but this target deliberately refuses them instead of emitting a
@@ -865,6 +881,14 @@ export async function compile(entryPath: string, opts: CompileOptions): Promise<
if (unavailable !== null) {
return fail([targetRefusalDiag("wasm32-wasi", unavailable.surface, unavailable.loc)]);
}
+ if (opts.backend === "c") {
+ const asyncSurface = moduleLibAsyncSurface(lowered.module);
+ if (asyncSurface !== null) {
+ return fail([
+ backendRefusalDiag("c", "wasm32-wasi", asyncSurface.surface, asyncSurface.loc),
+ ]);
+ }
+ }
}
entryText = fe.entryText();
sourceTexts = fe.sourceTexts();
diff --git a/packages/compiler/src/ir/nodes.ts b/packages/compiler/src/ir/nodes.ts
index 6c4d75b01..d9b355f52 100644
--- a/packages/compiler/src/ir/nodes.ts
+++ b/packages/compiler/src/ir/nodes.ts
@@ -720,7 +720,14 @@ export interface IrModule {
* island loader evaluates when an ES module imports the CJS file:
* default plus the named exports LEXED from the source at build time
* (cjs-lexer.ts — the compiler's port of Node's vendored CJS lexer). */
- modules: { key: string; source: string; format: "esm" | "cjs" | "json"; esm?: string }[];
+ modules: {
+ key: string;
+ source: string;
+ format: "esm" | "cjs" | "json";
+ esm?: string;
+ /** Parsed/bound embedded source reaches the engine's global fetch. */
+ usesFetch?: true;
+ }[];
/** `kind` picks Node's "exports" condition set per CALL FORM: one
* (from, specifier) can name a dual package's ESM entry behind an
* "import" edge AND its CJS entry behind a "require" edge; "any"
@@ -5856,16 +5863,15 @@ export function moduleUsesFileHandle(mod: IrModule): boolean {
}
/** True when user code lowers static fetch or the embedded npm graph
- * references dynamic fetch — the link switch that pulls scr_fetch.c +
+ * reaches the engine's global fetch — the link switch that pulls scr_fetch.c +
* its socket/tls/zlib dependencies into the binary (cc.ts) and has the
- * emitted main call scr_fetch_install. For embedded sources, use a
- * word-boundary scan and err toward linking: a false positive costs one
- * dylib reference; a false negative would leave embedded code without
- * the global at runtime. Fetch-free graphs keep their exact historical
- * link lines. */
+ * emitted main call scr_fetch_install. The npm graph records the embedded
+ * source fact with a parser + binder, so comments, strings, property names,
+ * and local `fetch` bindings do not change the link or target capability.
+ * Fetch-free graphs keep their exact historical link lines. */
export function moduleUsesFetch(mod: IrModule): boolean {
const embedded = mod.embedded;
- if (embedded && embedded.modules.some((m) => /\bfetch\b/.test(m.source))) return true;
+ if (embedded && embedded.modules.some((m) => m.usesFetch === true)) return true;
// User-code fetch is either the engine global or the static libCall pair.
let found = false;
const visit = (v: unknown): void => {
diff --git a/packages/compiler/test/npm-fetch-analysis.test.ts b/packages/compiler/test/npm-fetch-analysis.test.ts
new file mode 100644
index 000000000..1b87c8551
--- /dev/null
+++ b/packages/compiler/test/npm-fetch-analysis.test.ts
@@ -0,0 +1,42 @@
+import { expect, test } from "vitest";
+import {
+ embeddedModulesUsingGlobalFetch,
+ type EmbeddedModule,
+} from "../src/frontend/npm.js";
+
+function js(key: string, source: string): EmbeddedModule {
+ return { key: `/${key}.js`, source, format: "cjs" };
+}
+
+test("embedded fetch capability analysis ignores text and local bindings", () => {
+ const modules = [
+ js("comment", "// fetch(url)\nmodule.exports = 1;"),
+ js("string", "module.exports = 'fetch(url)';"),
+ js("property", "const value = { fetch: 1 }; module.exports = value.fetch;"),
+ js("local", "const fetch = (x) => x; module.exports = fetch('local');"),
+ js("parameter", "module.exports = function (fetch) { return fetch('local'); };"),
+ js("import", "import fetch from 'a-local-package'; export default fetch('local');"),
+ js("shadow-global", "module.exports = function (globalThis) { return globalThis.fetch('local'); };"),
+ ];
+
+ expect([...embeddedModulesUsingGlobalFetch(modules)]).toEqual([]);
+});
+
+test("embedded fetch capability analysis finds global reads", () => {
+ const modules = [
+ js("bare", "module.exports = fetch('https://example.com');"),
+ js("global-this", "module.exports = globalThis.fetch('https://example.com');"),
+ js("global", "module.exports = global['fetch']('https://example.com');"),
+ {
+ ...js("windows-path", "module.exports = fetch('https://example.com');"),
+ key: "C:\\pkg\\index.js",
+ },
+ ];
+
+ expect([...embeddedModulesUsingGlobalFetch(modules)]).toEqual([
+ "/bare.js",
+ "/global-this.js",
+ "/global.js",
+ "C:\\pkg\\index.js",
+ ]);
+});
diff --git a/packages/runtime/src/scr_events_emitter.c b/packages/runtime/src/scr_events_emitter.c
index 41deb177b..b2252526f 100644
--- a/packages/runtime/src/scr_events_emitter.c
+++ b/packages/runtime/src/scr_events_emitter.c
@@ -390,10 +390,10 @@ ScrEmitter *scr_emitter_on_via(ScrEmitter *em, ScrStr *name, ScrClosure *orig /*
/* ── fixed-arity invoke shims (scr_runtime.h's contract) ──────────────
* Read k pointer-size slots and call the fixed-signature adapter behind
- * cb->fn. The LLVM lane's emit sites pass every tuple argument pointer-
- * classed (f64 as its i64 bit pattern, bool zero-extended) and the
- * runtime's own emits are pointer-only, so va_arg(ap, void *) reads every
- * tuple a fixed-registered listener can observe. */
+ * cb->fn. The LLVM lane's emit sites pass scalars by pointer to typed stack
+ * slots and references directly; the runtime's own emits are pointer-only,
+ * so va_arg(ap, void *) reads every tuple a fixed-registered listener can
+ * observe on 32- and 64-bit targets. */
void scr_ee_inv_fixed0(ScrClosure *cb, va_list ap) {
(void)ap;
((void (*)(ScrClosure *))cb->fn)(cb);
diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h
index 76e8178a5..d17e3e6e7 100644
--- a/packages/runtime/src/scr_runtime.h
+++ b/packages/runtime/src/scr_runtime.h
@@ -1427,9 +1427,10 @@ ScrEmitter *scr_emitter_on_via(ScrEmitter *em, ScrStr *name, ScrClosure *orig /*
* slots off the emit tuple and calls cb->fn behind the fixed signature
* `void (ScrClosure *, void * ×k)` — the fixed-signature adapter the
* backend provides. Such backends pass every user tuple argument
- * pointer-classed at the emit site (f64 as its i64 bit pattern, bool
- * zero-extended); the runtime's own emits (data/error/pipe/meta) carry
- * pointers only, so the shims read every tuple either lane produces.
+ * pointer-classed at the emit site: scalar values point at call-lived
+ * typed stack slots and reference values ride directly. The runtime's own
+ * emits (data/error/pipe/meta) carry pointers only, so the shims read every
+ * tuple either lane produces on 32- and 64-bit targets.
* SCR_EE_FIXED_MAX is the registry's audited arity ceiling — backends
* refuse listeners past it rather than guess. */
#define SCR_EE_FIXED_MAX 4
diff --git a/tests/harness/wasi-differential.test.ts b/tests/harness/wasi-differential.test.ts
index e7a7f9919..0009e1dc3 100644
--- a/tests/harness/wasi-differential.test.ts
+++ b/tests/harness/wasi-differential.test.ts
@@ -105,6 +105,7 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => {
"1551-dyn-receiver-methods.ts",
"1558-any-joins-and-dyn-validation.ts",
"1634-inspect-classes.ts",
+ "1644-ee-basics.ts",
"1666-dyn-fn-identity.ts",
"1800-immediate-basics.ts",
"1805-timer-callback-args.ts",
@@ -152,6 +153,50 @@ describe.skipIf(!zigOnPath())("wasm32-wasi differential", () => {
if (llvmResult.ok) expect(llvmResult.backend).toBe("llvm");
});
+ test("the explicit C backend still emits async-free wasm", async () => {
+ const outDir = await mkdtemp("/tmp/scriptc-wasi-c-");
+ const result = await compile(join(repoRoot, "tests/corpus/001-hello.ts"), {
+ outDir,
+ outPath: join(outDir, "program.wasm"),
+ backend: "c",
+ });
+ if (!result.ok) {
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ }
+ expect(result.backend).toBe("c");
+ });
+
+ test("the explicit C backend diagnoses coroutine-dependent programs", async () => {
+ const outDir = await mkdtemp("/tmp/scriptc-wasi-c-refusal-");
+ const result = await compile(join(repoRoot, "tests/corpus/1020-async-basics.ts"), {
+ outDir,
+ outPath: join(outDir, "program.wasm"),
+ backend: "c",
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.diagnostics).toHaveLength(1);
+ expect(result.diagnostics[0]?.code).toBe("SC3001");
+ expect(result.diagnostics[0]?.message).toMatch(
+ /c backend does not support an async function .* for wasm32-wasi; use --backend llvm/,
+ );
+ }
+ });
+
+ test("embedded source comments mentioning fetch do not trigger SC3002", async () => {
+ const entry = join(repoRoot, "tests/fixtures/npm/cases/island-web-plumbing/main.ts");
+ const outDir = await mkdtemp("/tmp/scriptc-wasi-fetch-comment-");
+ const result = await compile(entry, {
+ outDir,
+ outPath: join(outDir, "program.wasm"),
+ dynamic: true,
+ });
+ if (!result.ok) {
+ throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
+ }
+ expect(result.backend).toBe("llvm");
+ });
+
test.each([
["1360-spawn-sync.ts", /child processes/],
["1443-signal-handlers.ts", /OS signals/],