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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-18 13:34:58 -07:00
co-authored by Claude Opus 5.5
parent 9fab4348d3
commit acfdc7fe13
10 changed files with 214 additions and 7 deletions
@@ -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.
@@ -900,6 +900,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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<string>([
"timers.queueMicrotaskDyn",
"timers.setImmediateFnValue",
"timers.immediatePromise",
"dyn.builtinFunction",
]);
@@ -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
@@ -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<string> = 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<string> = 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",
@@ -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)) {
@@ -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
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6236-builtin-function-values.cjs"
],
"diags": []
}
+96
View File
@@ -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
+6
View File
@@ -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);
@@ -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);