From acfdc7fe13e217e48a9993fe700ef9bc522c7538 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 12:49:31 -0700 Subject: [PATCH] Store builtin timer functions as real function values in JavaScript `var nextTick = typeof setImmediate === "function" ? setImmediate : setTimeout` (bcryptjs) and `cond ? setImmediate : process.nextTick` stored the builtin as an identity token string, so typeof answered "string" and calling it threw "nextTick is not a function". The JavaScript identifier, `globalThis.X` and destructuring spellings of setTimeout, setInterval, setImmediate, clearTimeout, clearInterval, clearImmediate and queueMicrotask, and a stored process.nextTick, now read one interned runtime function per name. Conditional, logical and assigned values carry it like any function: typeof is "function", identity holds across spellings, and calls validate the callback (ERR_INVALID_ARG_TYPE), coerce the delay, pass the extra arguments and return the same numeric handles the direct calls return, which the stored clear functions accept. Co-Authored-By: Claude Opus 5.5 --- .../limitations/builtin-function-values.mdx | 2 + .../compiler/src/backend/llvm/lib-shared.ts | 2 + .../src/frontend/lowering/builtins/process.ts | 6 ++ .../src/frontend/lowering/lower-exprs.ts | 43 +++++++-- .../src/frontend/lowering/lower-stmts.ts | 2 +- .../compiler/src/ir/builtin-signatures.ts | 6 ++ .../6236-builtin-function-values.cjs.json | 6 ++ packages/runtime/src/scr_async.c | 96 +++++++++++++++++++ packages/runtime/src/scr_dyn.h | 6 ++ tests/corpus/6236-builtin-function-values.cjs | 52 ++++++++++ 10 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-builtin-function-values.cjs.json create mode 100644 tests/corpus/6236-builtin-function-values.cjs diff --git a/docs/content/limitations/builtin-function-values.mdx b/docs/content/limitations/builtin-function-values.mdx index 4fd093e47..e87f86e13 100644 --- a/docs/content/limitations/builtin-function-values.mdx +++ b/docs/content/limitations/builtin-function-values.mdx @@ -5,6 +5,8 @@ Some builtins can be stored and passed as functions: - `util.isDeepStrictEqual`, `styleText`, `stripVTControlCharacters`, `toUSVString`, `inherits`, `deprecate`, `callbackify`, `debuglog`, and `debug`. - `fs.existsSync`, `unlinkSync`, `chmodSync`, `chownSync`, `renameSync`, and `closeSync`, and `fs/promises.unlink`, `chmod`, and `rename`. +In JavaScript, `setTimeout`, `setInterval`, `setImmediate`, `clearTimeout`, `clearInterval`, `clearImmediate`, `queueMicrotask`, and `process.nextTick` are function values wherever they are stored, including conditional and logical expressions and assignments: `typeof` answers `"function"`, identity holds across spellings, and calls schedule as the direct calls do, returning the same numeric timer handles. Calls through these values bypass `node:test` mock timers. Other builtin globals stored in JavaScript remain opaque identity values. + Other immutable aliases of table-backed Node builtins support direct calls, but cannot be passed elsewhere. `util.promisify` supports compile-time conversions of `child_process.execFile`, `child_process.exec`, `setTimeout` and `setImmediate` (to the `timers/promises` functions, as in Node), `stream.finished` and `stream.pipeline` (to the `stream/promises` functions, as in Node), the `zlib` callback one-shots `deflate`, `inflate`, `deflateRaw`, `inflateRaw`, `gzip`, `gunzip` and `unzip` (with or without options), the `crypto` callback functions `randomBytes`, `pbkdf2`, `scrypt`, `hkdf`, `randomFill`, `randomInt` and `generateKey`, `dns.lookup` (settling with `{ address, family }`, or the address array with `all: true`) and `dns.lookupService` (settling with `{ hostname, service }`), and the `fs` callback functions that have an `fs/promises` counterpart (`access`, `appendFile`, `chmod`, `chown`, `copyFile`, `lchown`, `link`, `lstat`, `lutimes`, `mkdir`, `mkdtemp`, `readFile`, `readdir`, `readlink`, `realpath`, `rename`, `rm`, `rmdir`, `stat`, `statfs`, `symlink`, `truncate`, `unlink`, `utimes`, and `writeFile`); calls through the converted function lower as the `fs/promises` call. `util.styleText` supports standard formats and aliases, `#RGB` and `#RRGGBB` hex colors, arrays of formats, nested ANSI resets, and explicit color enabling with `{ validateStream: false }`. Native calls accept standard process streams; custom native streams and mutations to `util.inspect.colors` remain unsupported. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 3f36e0d7c..45662d3cc 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -900,6 +900,7 @@ export const LIB_FN_SYMS: Record = { "dyn.nativeUrlIs": "scr_dyn_native_url_is", "dyn.nativeDateIs": "scr_dyn_native_date_is", "dyn.globalConstructor": "scr_dyn_global_constructor", + "dyn.builtinFunction": "scr_dyn_builtin_function", "date.nativeNew": "scr_dyn_native_date_new", "date.nativeNewFields": "scr_dyn_native_date_new_fields", "date.checkedValue": "scr_dyn_native_date_value", @@ -1612,4 +1613,5 @@ export const USES_TIMERS_LIB_FNS = new Set([ "timers.queueMicrotaskDyn", "timers.setImmediateFnValue", "timers.immediatePromise", + "dyn.builtinFunction", ]); diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 94c2cdba7..c9935a744 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -26,6 +26,7 @@ import { funcOf, } from "../../../ir/ir.js"; import { lowerBuiltinLoaderValue } from "../lower-builtin-values.js"; +import { builtinFunctionValue } from "../lower-exprs.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; @@ -385,6 +386,11 @@ export function lowerProcessProperty( if (member === "hrtime") { return { kind: "libCall", fn: "process.hrtimeValue", args: [], type: DYN, loc }; } + // A stored process.nextTick in JavaScript (`cond ? setImmediate : + // process.nextTick`): the interned builtin function value. + if (member === "nextTick" && isJsSourceFile(expr.getSourceFile())) { + return builtinFunctionValue("process.nextTick", loc); + } if (member === "getBuiltinModule") { // Keep the native loader's argument validation when the function is // stored. The declaration's string signature must not insert a checked diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index d3f1f4676..02565e547 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -1453,8 +1453,12 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const canonical = stdlibGlobalNameOf(lowerer, expr) ?? expr.text; // Builtin constructors have real function values in both source // languages (TypeScript reads them as dynamic values). - const constructorValue = builtinGlobalValue(canonical, loc); - if (constructorValue !== null) return constructorValue; + const builtinValue = builtinGlobalValue( + canonical, + loc, + isJsSourceFile(expr.getSourceFile()), + ); + if (builtinValue !== null) return builtinValue; if (isJsSourceFile(expr.getSourceFile())) { return { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, loc }; } @@ -2354,7 +2358,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { loc, }; return ( - builtinGlobalValue(canonical, loc) ?? { + builtinGlobalValue(canonical, loc, true) ?? { kind: "strLit", value: `[builtin ${canonical}]`, type: STRING, @@ -16330,9 +16334,36 @@ const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet = new Set([ "WeakSet", ]); -/** The value a JavaScript source reads for the stdlib global `canonical`, - * or null when it stays an identity token. */ -export function builtinGlobalValue(canonical: string, loc: SrcLoc): IrExpr | null { +/** The builtin global functions JavaScript sources read as interned + * function values (the `cond ? setImmediate : process.nextTick` feature + * probe of bcryptjs and async libraries): typeof answers "function", + * identity holds across spellings, and calls run the timer APIs. */ +const GLOBAL_FUNCTION_VALUES: ReadonlySet = new Set([ + "setTimeout", + "setInterval", + "setImmediate", + "clearTimeout", + "clearInterval", + "clearImmediate", + "queueMicrotask", +]); + +/** A builtin function value: one interned runtime function per name. */ +export function builtinFunctionValue(name: string, loc: SrcLoc): IrExpr { + return { + kind: "libCall", + fn: "dyn.builtinFunction", + args: [{ kind: "strLit", value: name, type: STRING, loc }], + type: DYN, + loc, + }; +} + +/** The value a source reads for the stdlib global `canonical` (builtin + * constructors in both languages, builtin functions in JavaScript), or + * null when it stays an identity token (JavaScript) or fenced. */ +export function builtinGlobalValue(canonical: string, loc: SrcLoc, js = false): IrExpr | null { + if (js && GLOBAL_FUNCTION_VALUES.has(canonical)) return builtinFunctionValue(canonical, loc); if (GLOBAL_CONSTRUCTOR_VALUES.has(canonical)) { return { kind: "libCall", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 78c3e76d3..315464289 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -3337,7 +3337,7 @@ function stdlibGlobalTokenDestructure( alias = canonical; // Builtin constructors read as their interned function values, the // same values the bare and `globalThis.X` spellings read. - value = builtinGlobalValue(canonical, locOf(el)); + value = builtinGlobalValue(canonical, locOf(el), true); } else if (globalName === "console" && !CONSOLE_CALL_MEMBERS.has(propName)) { token = `[builtin console.${propName}]`; } else if (TOKEN_OPAQUE_GLOBALS.has(globalName)) { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 74956d161..3709ae4e1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -210,6 +210,12 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * interned function per name, the same one dynamic values answer for * `.constructor`. The name is a compiler-chosen literal. */ "dyn.globalConstructor": { argTypes: [STRING], result: DYN }, + /** A builtin global function (setTimeout, setInterval, setImmediate, + * the clear functions, queueMicrotask, process.nextTick) read as a + * JavaScript value: one interned function per name whose calls run the + * timer APIs (it pulls in the event loop). The name is a compiler-chosen + * literal. */ + "dyn.builtinFunction": { argTypes: [STRING], result: DYN }, "date.nativeNew": { argTypes: [DYN], result: DYN }, /** `new Date(year, monthIndex, ...)` in JavaScript: the argument array's * first seven values pass through ToNumber in order (later ones are diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-builtin-function-values.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-builtin-function-values.cjs.json new file mode 100644 index 000000000..21b9f85c2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-builtin-function-values.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6236-builtin-function-values.cjs" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 48f3a5329..58c7f653e 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -3717,6 +3717,102 @@ ScrDyn *scr_set_immediate_dyn_value(void) { 1, "(cb,...)", "setImmediate"); } +/* ── Builtin global functions as dynamic values ───────────────────────── + * A JavaScript source that stores a builtin global function (the + * `var nextTick = typeof setImmediate === "function" ? setImmediate : + * setTimeout` feature probe) reads one interned function per name, so + * typeof answers "function" and `===` is identity. Calls through it run + * the timer and tick APIs with Node's argument contract: a function + * callback (else ERR_INVALID_ARG_TYPE), the delay through ToNumber, the + * remaining arguments passed to the callback. Timer functions answer the + * same numeric handles the direct calls answer, which the clear functions + * accept. A name without a dynamic call path throws a named fence. */ +static SCR_TL ScrDyn *scr_builtin_fn_table; + +static void scr_builtin_fn_cleanup(void) { + scr_dyn_release(scr_builtin_fn_table); + scr_builtin_fn_table = NULL; +} + +static bool scr_builtin_fn_callback(ScrDyn *const *args, size_t argc) { + if (argc > 0 && args[0]->kind == SCR_DYN_FUNC) return true; + static const char msg[] = "The \"callback\" argument must be of type function."; + scr_throw_error_msg_code(SCR_ERR_TYPE, msg, sizeof msg - 1, "ERR_INVALID_ARG_TYPE"); + return false; +} + +/* The closure that calls args[0] with args[first..] when the loop fires it. */ +static ScrClosure *scr_builtin_fn_deferred(ScrDyn *const *args, size_t argc, size_t first) { + ScrDyn *rest = scr_dyn_new_arr(); + for (size_t i = first; i < argc; i++) scr_dyn_arr_push(rest, scr_dyn_retain(args[i])); + ScrClosure *c = scr_closure_new((void *)scr_imm_dyn_fire, 2); + c->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, NULL); + scr_box_set_ref(c->caps[0], scr_dyn_retain(args[0])); + c->caps[1] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, NULL); + scr_box_set_ref(c->caps[1], rest); /* moves */ + return c; +} + +static ScrDyn *scr_builtin_fn_call(ScrClosure *clo, ScrDyn *const *args, size_t argc) { + ScrStr *name = scr_box_get_ref(clo->caps[0]); + ScrDyn *result = NULL; + bool timeout = strcmp(name->data, "setTimeout") == 0; + if (timeout || strcmp(name->data, "setInterval") == 0) { + double ms = 0; + if (scr_builtin_fn_callback(args, argc) && (argc < 2 || scr_dyn_number_coerce_js(args[1], &ms))) { + ScrClosure *c = scr_builtin_fn_deferred(args, argc, 2); + result = scr_dyn_new_num(timeout ? scr_set_timeout_handle(c, ms) : scr_set_interval(c, ms)); + } + } else if (strcmp(name->data, "setImmediate") == 0) { + if (scr_builtin_fn_callback(args, argc)) + result = scr_dyn_new_num(scr_set_immediate(scr_builtin_fn_deferred(args, argc, 1))); + } else if (strcmp(name->data, "process.nextTick") == 0) { + if (scr_builtin_fn_callback(args, argc)) { + scr_next_tick(scr_builtin_fn_deferred(args, argc, 1)); + result = scr_dyn_retain(scr_dyn_undefined()); + } + } else if (strcmp(name->data, "queueMicrotask") == 0) { + if (scr_builtin_fn_callback(args, argc)) { + scr_queue_microtask_dyn(args[0]); + result = scr_dyn_retain(scr_dyn_undefined()); + } + } else if (strcmp(name->data, "clearTimeout") == 0 || strcmp(name->data, "clearInterval") == 0 || + strcmp(name->data, "clearImmediate") == 0) { + /* Node ignores anything that is not a live handle. */ + if (argc > 0 && args[0]->kind == SCR_DYN_NUM) { + if (name->data[5] == 'I' && name->data[6] == 'm') scr_clear_immediate(args[0]->v.num); + else scr_clear_interval(args[0]->v.num); + } + result = scr_dyn_retain(scr_dyn_undefined()); + } else { + char message[160]; + int length = snprintf(message, sizeof message, "calling '%s' through a stored value is not supported yet", name->data); + scr_throw_error_msg_code(SCR_ERR_ERROR, message, (size_t)length, "SC2020"); + } + scr_str_release(name); + return result; +} + +ScrDyn *scr_dyn_builtin_function(const ScrStr *name) { + if (!scr_builtin_fn_table) { + scr_builtin_fn_table = scr_dyn_new_obj_null_proto(); + scr_atexit(scr_builtin_fn_cleanup); + } + ScrDyn *value = scr_dyn_obj_get(scr_builtin_fn_table, name->data, name->len); + if (!value) { + ScrClosure *closure = scr_closure_new(NULL, 1); + closure->caps[0] = scr_box_new(SCR_BOX_STR); + scr_box_set_ref(closure->caps[0], scr_str_retain((ScrStr *)name)); + const char *dot = memchr(name->data, '.', name->len); + size_t arity = strcmp(name->data, "queueMicrotask") == 0 || strncmp(name->data, "clear", 5) == 0 ? 1 : + strcmp(name->data, "setImmediate") == 0 || strcmp(name->data, "process.nextTick") == 0 ? 1 : 2; + value = scr_dyn_new_func(closure, scr_builtin_fn_call, arity, "native:builtin", + dot ? dot + 1 : name->data); + scr_dyn_obj_set(scr_builtin_fn_table, name->data, name->len, value); /* the table owns it */ + } + return scr_dyn_retain(value); +} + /* ── queueMicrotask ────────────────────────────────────────────────── * The READY queue's FIFO is the microtask queue (promise continuations * ride it as parked fibers), so a queueMicrotask callback enters the SAME diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 738c5d487..d497b45ac 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -655,6 +655,12 @@ ScrDyn *scr_dyn_array_constructor(void); /* The interned builtin constructor `name` (Object, Date, RegExp, Map, Set, * Promise, WeakMap, WeakSet) as a dynamic function value: +1. */ ScrDyn *scr_dyn_global_constructor(const ScrStr *name); +/* A builtin global function (setTimeout, setImmediate, process.nextTick, ...) + * as an interned dynamic function value: +1. */ +ScrDyn *scr_dyn_builtin_function(const ScrStr *name); +/* Object.prototype's valueOf/toString/hasOwnProperty/propertyIsEnumerable as + * interned function values (+1), or NULL for other names. */ +ScrDyn *scr_dyn_object_proto_method(const char *name); bool scr_dyn_arr_has_index(const ScrDyn *arr, size_t index); void scr_dyn_arr_push_hole(ScrDyn *arr); ScrDyn *scr_dyn_new_obj(void); diff --git a/tests/corpus/6236-builtin-function-values.cjs b/tests/corpus/6236-builtin-function-values.cjs new file mode 100644 index 000000000..b8abee311 --- /dev/null +++ b/tests/corpus/6236-builtin-function-values.cjs @@ -0,0 +1,52 @@ +// Builtin timer functions stored as values (bcryptjs's `nextTick` feature +// probe, async libraries' scheduler selection): conditional, logical and +// assigned values are real functions — typeof answers "function", identity +// holds across spellings, and calls schedule with Node's argument contract +// and ordering. +var nextTick = + typeof setImmediate === "function" + ? setImmediate + : typeof queueMicrotask === "function" + ? queueMicrotask + : setTimeout; +const pick = process.argv.length > 100; +const later = pick ? setImmediate : process.nextTick; +const timeout = pick ? setImmediate : setTimeout; +const micro = pick || queueMicrotask; +let assigned = setTimeout; +assigned = pick ? assigned : setImmediate; +const { setInterval: every, clearInterval: stop } = globalThis; +console.log(typeof nextTick, typeof later, typeof timeout, typeof micro, typeof assigned, typeof every); +console.log(nextTick === setImmediate, later === process.nextTick, timeout === setTimeout, micro === queueMicrotask, assigned === globalThis.setImmediate, every === setInterval, stop === clearInterval); +console.log(nextTick.name, later.name, timeout.name, setTimeout.length, queueMicrotask.length); + +const order = []; +nextTick(function () { order.push("immediate:" + arguments.length); }); +later((a, b) => order.push("tick:" + a + b), 1, 2); +// Microtask and nextTick queues order differently in CommonJS and ESM +// entry evaluation, so the microtask reports through its own flag. +let microRan = false; +micro(() => { microRan = true; }); +timeout((x) => order.push("timeout:" + x), 60, "x"); +assigned(() => order.push("assigned")); +const cancelled = timeout(() => order.push("cancelled"), 1); +(pick ? clearImmediate : clearTimeout)(cancelled); +let count = 0; +const handle = every(() => { + count++; + order.push("interval:" + count); + if (count === 2) stop(handle); +}, 20); +timeout(() => console.log(order.join(" "), "micro:" + microRan), 120); + +for (const bad of [undefined, 1, "fn"]) { + try { + later(bad); + } catch (e) { + console.log("rejected", typeof bad, e.code, e instanceof TypeError); + } +} +const log = pick ? console.error : console.log; +log("log", typeof log); +const mx = pick ? Math.min : Math.max; +console.log(mx(1, 3, 2), typeof mx);