Files
scriptc/tests/corpus/fiberless-async-ordering.ts
Malte Ubl 363fb69f07 Run await-free async functions without a fiber
An async function whose body can never park its execution context
completes before its call returns, exactly like a fiber that never
suspends. The LLVM emitter now proves that from the emitted IR: a body
qualifies when neither its own text nor that of any module function it
mentions (transitively) contains a suspension entry point (scr_await_*,
scr_module_await, scr_promise_await_settled, scr_test_sub, generator
yields, async-generator operations, fiber accessors). wasm32, programs
embedding the dynamic engine, module-evaluation bodies, dyn results, and
programs using node:test subtests (t.test awaits on the current fiber
even from sync code) keep fibers. Qualifying spawn wrappers call the body
on the caller's stack between scr_async_inline_enter/leave, which save
and restore what a fresh fiber isolates (the Error.stack frame chain and
the AsyncLocalStorage context), then settle a fresh promise from the
outcome through the same fulfill/reject paths the fiber trampoline uses.
A park or hop while a fiberless frame is on a fiber's stack traps as an
internal error rather than reordering.

In async-pipeline, loadUser and authorize become fiberless: one fiber
per request instead of five. New corpus programs pin microtask ordering,
rejections (handled and unhandled), finally, nested suspending spawns,
closures, methods, Error.stack, AsyncLocalStorage, and a fiber-switch
stress test.

Final benchmarks for the branch (three commits) vs perf/harness, runs=15.

macOS arm64 (local):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 279.2 | 280.7 | +0.5% | -1.8% .. +3.0% | neutral | +0.0% | 230.93 | 1.22x |
| regex-logs | 305.8 | 306.0 | +0.1% | -0.8% .. +2.0% | neutral | +0.0% | 132.42 | 2.31x |
| ast-interp | 235.3 | 235.1 | -0.1% | -0.6% .. +0.4% | neutral | +0.0% | 133.55 | 1.76x |
| records-sort | 621.6 | 619.0 | -0.4% | -1.1% .. +0.6% | neutral | +0.0% | 131.56 | 4.71x |
| template-render | 202.8 | 201.9 | -0.4% | -1.7% .. +2.6% | neutral | +0.0% | 99.28 | 2.03x |
| functional-pipeline | 226.0 | 226.4 | +0.2% | -0.4% .. +0.6% | neutral | +0.0% | 120.24 | 1.88x |
| async-pipeline | 267.0 | 21.6 | -91.9% | -92.2% .. -91.4% | faster | +0.0% | 58.58 | 0.37x |
| word-graph | 417.6 | 566.8 | +35.7% | -49.6% .. +171.5% | neutral | +0.0% | 944.76 | 0.60x |
| alloc-trees | 311.1 | 311.3 | +0.1% | -4.8% .. +2.3% | neutral | +0.0% | 87.37 | 3.56x |
| numeric-kernels | 79.0 | 80.5 | +1.9% | -4.1% .. +8.4% | neutral | +0.0% | 94.05 | 0.86x |
| validate-errors | 169.9 | 169.4 | -0.3% | -12.0% .. +12.3% | neutral | +0.0% | 517.13 | 0.33x |
| csv-numbers | 379.3 | 372.8 | -1.7% | -27.8% .. +12.5% | neutral | +0.0% | 360.69 | 1.03x |
| log-summary | 180.7 | 184.0 | +1.8% | -14.4% .. +9.1% | neutral | +0.0% | 181.51 | 1.01x |
| inventory-report | 231.7 | 239.0 | +3.2% | -11.1% .. +10.9% | neutral | +0.0% | 245.91 | 0.97x |
(word-graph was host noise from concurrent agents; the immediate recheck
measured -2.0%, neutral.)

Linux x86_64 (sandbox):
| workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node |
| --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: |
| json-records | 1031.8 | 1019.7 | -1.2% | -5.5% .. +3.2% | neutral | +0.0% | 627.39 | 1.63x |
| regex-logs | 534.1 | 533.6 | -0.1% | -5.5% .. +1.9% | neutral | +0.0% | 336.44 | 1.59x |
| ast-interp | 538.1 | 536.8 | -0.2% | -1.7% .. +2.3% | neutral | +0.0% | 324.66 | 1.65x |
| records-sort | 1798.6 | 1749.9 | -2.7% | -4.8% .. +0.5% | neutral | +0.0% | 326.49 | 5.36x |
| template-render | 379.2 | 371.0 | -2.1% | -9.3% .. +1.0% | neutral | +0.0% | 287.51 | 1.29x |
| functional-pipeline | 608.5 | 596.7 | -1.9% | -3.9% .. +3.2% | neutral | +0.0% | 390.16 | 1.53x |
| async-pipeline | 250.3 | 35.9 | -85.7% | -86.2% .. -85.3% | faster | -0.0% | 123.43 | 0.29x |
| word-graph | 462.1 | 461.7 | -0.1% | -3.3% .. +2.4% | neutral | +0.0% | 690.81 | 0.67x |
| alloc-trees | 690.4 | 699.1 | +1.3% | -0.9% .. +4.1% | neutral | +0.0% | 174.67 | 4.00x |
| numeric-kernels | 176.0 | 172.8 | -1.8% | -3.4% .. +0.6% | neutral | +0.0% | 176.9 | 0.98x |
| validate-errors | 304.4 | 301.0 | -1.1% | -4.9% .. +1.6% | neutral | +0.0% | 945.13 | 0.32x |
| csv-numbers | 602.8 | 605.0 | +0.4% | -4.7% .. +1.9% | neutral | +0.0% | 696.66 | 0.87x |
| log-summary | 332.9 | 332.6 | -0.1% | -4.0% .. +1.4% | neutral | +0.0% | 384.79 | 0.86x |
| inventory-report | 601.0 | 583.5 | -2.9% | -6.5% .. +5.6% | neutral | +0.0% | 585.31 | 1.00x |
2026-10-08 15:22:27 -05:00

157 lines
5.1 KiB
TypeScript

// Await-free async functions complete before their call returns, so the
// compiler runs them without a fiber. Their promises must still behave
// exactly like any other settled promise: awaiting one takes the microtask
// hop, then/catch reactions interleave with queueMicrotask and nextTick in
// Node's order, rejections surface at the await or reach a catch handler,
// and a fiberless body may spawn a suspending async function whose
// continuation resumes later from the event loop.
import { AsyncLocalStorage } from "node:async_hooks";
const log: string[] = [];
async function syncValue(n: number): Promise<number> {
log.push("syncValue " + n);
return n * 2;
}
async function syncVoid(label: string): Promise<void> {
console.log("syncVoid", label);
}
async function syncThrow(message: string): Promise<number> {
console.log("syncThrow", message);
throw new Error(message);
}
async function syncString(s: string): Promise<string> {
return s + "!";
}
async function syncBool(n: number): Promise<boolean> {
return n > 0;
}
interface Rec {
id: number;
tags: string[];
}
async function syncRecord(id: number): Promise<Rec> {
return { id, tags: ["t" + id, "u" + id] };
}
async function syncError(): Promise<Error> {
return new Error("made in an await-free body");
}
function helper(n: number): number {
return n + 1;
}
async function viaHelper(n: number): Promise<number> {
return helper(helper(n));
}
async function countdown(n: number): Promise<number> {
if (n === 0) return 0;
countdown(n - 1).then((v) => console.log("countdown settled", n - 1, v));
return n;
}
async function suspends(label: string): Promise<number> {
console.log("suspends start", label);
await null;
console.log("suspends resumed", label);
return 7;
}
async function fiberlessCallsSuspending(): Promise<number> {
const p = suspends("inner");
console.log("fiberless after spawn");
p.then((v) => console.log("inner settled", v));
return 1;
}
async function chain(label: string, n: number): Promise<number> {
let total = 0;
for (let i = 0; i < n; i++) {
const v = await syncValue(i);
console.log("chain", label, i, v);
total += v;
}
return total;
}
class Service {
readonly base: number;
constructor(base: number) {
this.base = base;
}
async get(n: number): Promise<number> {
return this.base + n;
}
async fail(): Promise<string> {
throw new RangeError("service failure " + this.base);
}
}
const store = new AsyncLocalStorage<string>();
async function readStore(): Promise<string> {
return store.getStore() ?? "none";
}
async function main(): Promise<void> {
const a = syncValue(1);
console.log("after syncValue call:", log.join(","));
queueMicrotask(() => console.log("microtask A"));
a.then((v) => console.log("then a", v));
Promise.resolve(5).then((v) => console.log("resolved then", v));
process.nextTick(() => console.log("tick"));
console.log("await a", await a);
console.log("string", await syncString("x"), "bool", await syncBool(2), await syncBool(-1));
const record = await syncRecord(3);
console.log("record", record.id, record.tags.join("|"));
await syncVoid("v");
console.log("helper", await viaHelper(1));
try {
await syncThrow("bad");
} catch (e) {
console.log("caught", (e as Error).message);
}
const rejected = syncThrow("later");
console.log("rejected created");
rejected.catch((e: unknown) => console.log("catch handler", e instanceof Error ? e.message : e));
try {
await rejected;
} catch (e) {
console.log("caught again", (e as Error).message);
}
const fanout: Promise<number>[] = [syncValue(10), syncValue(11), suspends("all")];
const all = await Promise.all(fanout);
console.log("all", all.join(","));
const mapped = await Promise.all([1, 2, 3].map(syncValue));
console.log("mapped", mapped.join(","));
const raced = await Promise.race([suspends("race"), syncValue(20)]);
console.log("raced", raced);
const chains: Promise<number>[] = [chain("A", 3), chain("B", 3)];
const totals = await Promise.all(chains);
console.log("totals", totals.join(","));
try {
try {
throw new Error("outer");
} finally {
syncThrow("in finally").catch((e: unknown) =>
console.log("finally rejection", e instanceof Error ? e.message : e),
);
}
} catch (e) {
console.log("outer survived", (e as Error).message);
}
console.log("fiberless->suspending", await fiberlessCallsSuspending());
console.log("countdown", await countdown(3));
let counter = 0;
const bump = async (k: number): Promise<number> => {
counter += k;
return counter;
};
console.log("closure", await bump(2), await bump(3), counter);
const service = new Service(100);
console.log("method", await service.get(1));
try {
await service.fail();
} catch (e) {
console.log("method rejection", e instanceof RangeError, (e as Error).message);
}
const error = await syncError();
console.log("stack", error.message, error.stack!.includes("at syncError"));
console.log("store", await store.run("scoped", readStore), await readStore());
}
main().then(() => console.log("main done"));
console.log("sync end");