From 5e964965f6abef96a36fe142e6780f7b81da4dfd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:19:59 -0700 Subject: [PATCH 01/11] Assign process.emitWarning pi's CLI setup replaces process.emitWarning to silence warnings. The assignment now stores the replacement: later process.emitWarning(...) calls apply it to their arguments, and runtime-built warnings (Node's own modules call process.emitWarning) reach it as (message, type[, code]) instead of the report and the 'warning' listeners. process.title writes already lowered; the corpus program pins both. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/lower-stmts.ts | 26 +++++++++ .../compiler/src/ir/builtin-signatures.ts | 1 + packages/compiler/src/ir/runtime-features.ts | 1 + .../6231-process-emit-warning-assign.ts.json | 6 ++ packages/runtime/src/scr_async.h | 6 ++ packages/runtime/src/scr_async_dyn.c | 58 +++++++++++++++++++ .../6231-process-emit-warning-assign.ts | 35 +++++++++++ 8 files changed, 134 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json create mode 100644 tests/corpus/6231-process-emit-warning-assign.ts diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 02bf371923..5f5a1223e1 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1306,6 +1306,7 @@ export const LIB_FN_SYMS: Record = { "process.emitWarningText": "scr_process_emit_warning_text", "process.deprecationFlag": "scr_process_deprecation_flag", "process.setDeprecationFlag": "scr_process_set_deprecation_flag", + "process.setEmitWarning": "scr_process_set_emit_warning", "process.onUncaughtException": "scr_process_on_uncaught_exception", "process.offUncaughtException": "scr_process_off_uncaught_exception", "process.onUnhandledRejection": "scr_process_on_unhandled_rejection", diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 0f1b606ef9..b7f0c2481f 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -6758,6 +6758,32 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm loc, }; } + // process.emitWarning = fn: an ordinary writable property. Later + // process.emitWarning(...) calls, and the warnings Node's own + // modules emit through process.emitWarning, call the replacement + // instead (silencing them when it does nothing). + if (flag === "emitWarning") { + const loc = locOf(expr); + let rhs: ts.Expression = expr.right; + while ( + ts.isParenthesizedExpression(rhs) || + ts.isAsExpression(rhs) || + ts.isSatisfiesExpression(rhs) + ) + rhs = rhs.expression; + const value = lowerer.lowerExprExpecting(rhs, DYN); + return { + kind: "exprStmt", + expr: { + kind: "libCall", + fn: "process.setEmitWarning", + args: [value], + type: VOID, + loc, + }, + loc, + }; + } // process.title = name: libuv's uv_set_process_title (the // runtime truncates to the original argument area on POSIX). if (flag === "title") { diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 08c493ae4e..539bd5eb88 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -4112,6 +4112,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * warnings and DeprecationWarning emissions read their truthiness. */ "process.deprecationFlag": { argTypes: [F64], result: DYN }, "process.setDeprecationFlag": { argTypes: [F64, DYN], result: VOID }, + "process.setEmitWarning": { argTypes: [DYN], result: VOID }, /** AsyncLocalStorage (node:async_hooks — scr_async.c): stores are f64 * handles; contexts are immutable fiber-carried snapshots (spawned * fibers inherit the spawner's — Node's init-time capture). run/exit diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index d16edfabd0..72f30a76e9 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -67,6 +67,7 @@ const DYN_ASYNC_LIB_FNS: ReadonlySet = new Set([ "process.emitWarningText", "process.deprecationFlag", "process.setDeprecationFlag", + "process.setEmitWarning", // The performance timeline (scr_perf.c, linked with this unit for its // MaxPerformanceEntryBufferExceededWarning). "perf.mark", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json new file mode 100644 index 0000000000..ac7be42ac0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6231-process-emit-warning-assign.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6231-process-emit-warning-assign.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index 7f294ca29f..e591c76559 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -212,6 +212,12 @@ void scr_process_on_warning(ScrDyn *fn); void scr_process_off_warning(ScrDyn *fn); void scr_process_emit_warning(ScrDyn *args); void scr_emit_warning(const char *name, const char *code, ScrStr *message); +/* process.emitWarning = fn (borrowed; the runtime keeps its own + * reference). Later process.emitWarning(...) calls apply the replacement + * to their arguments, and runtime-built warnings (Node's own modules call + * process.emitWarning) reach it as (message, type[, code]) instead of the + * report and the 'warning' listeners. */ +void scr_process_set_emit_warning(ScrDyn *fn); /* scr_emit_warning once a 'warning' listener exists (scr_lib.c; set by the * listener registration): units that cannot link the dyn-async unit route * their warnings through it, and print the default report otherwise. */ diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index b7387c13e6..b7e4539c01 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -860,10 +860,53 @@ ScrDyn *scr_warning_new(ScrStr *message, const char *type, const ScrStr *code) { return w; } +/* The program's process.emitWarning replacement, or NULL while the + * property holds Node's own function. */ +static SCR_TL ScrDyn *scr_emit_warning_override = NULL; + +static void scr_emit_warning_override_teardown(void) { + scr_dyn_release(scr_emit_warning_override); + scr_emit_warning_override = NULL; +} + +void scr_process_set_emit_warning(ScrDyn *fn) { + if (scr_emit_warning_override == NULL) scr_atexit(scr_emit_warning_override_teardown); + ScrDyn *previous = scr_emit_warning_override; + scr_emit_warning_override = scr_dyn_retain(fn); + scr_dyn_release(previous); + /* Units that report their own warnings without a listener route them + * here once a replacement exists. */ + scr_warning_emit_hook = scr_emit_warning; +} + +/* A runtime-built warning while a replacement is installed: Node's modules + * call process.emitWarning(message, type, code), so the replacement sees + * those arguments; nothing is reported. A throw stays pending. */ +static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *code) { + ScrDyn *args[3]; + size_t argc = 0; + args[argc++] = scr_dyn_new_str(message); + ScrStr *fallback = type ? NULL : scr_str_new("Warning", 7); + args[argc++] = scr_dyn_new_str(type ? type : fallback); + scr_str_release(fallback); + if (code) args[argc++] = scr_dyn_new_str(code); + ScrDyn *r = scr_dyn_call(scr_emit_warning_override, args, argc, "process.emitWarning"); + scr_dyn_release(r); + for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); +} + /* A warning built from C parts (the runtime deprecation sites): name * defaults to "Warning"; code/detail optional. Reported synchronously. * Borrows message. */ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { + if (scr_emit_warning_override != NULL) { + ScrStr *type_text = scr_str_new(name ? name : "Warning", strlen(name ? name : "Warning")); + ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; + scr_emit_warning_replaced(message, type_text, code_text); + scr_str_release(type_text); + scr_str_release(code_text); + return; + } ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; ScrDyn *w = scr_warning_new(message, name ? name : "Warning", code_text); scr_str_release(code_text); @@ -894,6 +937,16 @@ static void scr_warning_defer(ScrDyn *w, void (*fire)(ScrClosure *)) { } void scr_warning_emit_deferred(ScrDyn *w) { + if (scr_emit_warning_override != NULL) { + const ScrDyn *em = scr_dyn_obj_get(w, "message", 7); + const ScrDyn *ec = scr_dyn_obj_get(w, "code", 4); + ScrStr *name = scr_warning_name(w); + ScrStr *message = em && em->kind == SCR_DYN_STR ? scr_str_retain(em->v.str) : scr_str_new("", 0); + scr_emit_warning_replaced(message, name, ec && ec->kind == SCR_DYN_STR ? ec->v.str : NULL); + scr_str_release(message); + scr_str_release(name); + return; + } if (scr_warning_is_deprecation(w)) { if (scr_process_deprecation_flag_on(0)) return; if (scr_process_deprecation_flag_on(1)) { @@ -959,6 +1012,11 @@ static void scr_warn_bad_arg(const char *arg, const char *want) { * ERR_INVALID_ARG_TYPE TypeErrors at the call; non-string details are * ignored. The warning itself is emitted on the next tick. */ void scr_process_emit_warning(ScrDyn *args) { + if (scr_emit_warning_override != NULL) { + ScrDyn *r = scr_dyn_apply(scr_emit_warning_override, args, "process.emitWarning"); + scr_dyn_release(r); + return; + } size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; ScrDyn *const *items = args->kind == SCR_DYN_ARR ? args->v.arr.items : NULL; ScrDyn *warning = argc >= 1 ? items[0] : NULL; diff --git a/tests/corpus/6231-process-emit-warning-assign.ts b/tests/corpus/6231-process-emit-warning-assign.ts new file mode 100644 index 0000000000..1cae44f82f --- /dev/null +++ b/tests/corpus/6231-process-emit-warning-assign.ts @@ -0,0 +1,35 @@ +// process.emitWarning and process.title are ordinary writable properties +// (pi's CLI setup assigns both). After the replacement, process.emitWarning +// calls reach it with their own arguments, no report reaches stderr, and +// the warnings Node's own modules emit through process.emitWarning (a +// util.deprecate wrapper's first call) reach it too. +import { deprecate } from "node:util"; + +process.title = "pi"; +console.log("title", process.title); +process.env.PI_CODING_AGENT = "true"; +console.log("env", process.env.PI_CODING_AGENT); + +const seen: string[] = []; +process.on("warning", (w) => seen.push(`listener ${w.name}`)); +function show(a: unknown): string { + if (a instanceof Error) return `Error(${a.message})`; + return typeof a === "object" ? JSON.stringify(a) : String(a); +} +process.emitWarning = ((warning: unknown, type: unknown, code: unknown) => { + seen.push(`${show(warning)}|${show(type)}|${show(code)}`); +}) as typeof process.emitWarning; + +process.emitWarning("plain"); +process.emitWarning("typed", "CustomWarning", "CW_1"); +process.emitWarning("options", { type: "OptWarning", code: "OW_1", detail: "d" }); +process.emitWarning(new Error("as error")); +const old = deprecate(() => "result", "old() is deprecated", "DEP_SCRIPTC_1"); +console.log("deprecated call", old()); +console.log("second call", old()); +console.log("sync", seen.join(" ; ")); + +// A replacement that does nothing silences warnings entirely. +process.emitWarning = (() => {}) as typeof process.emitWarning; +process.emitWarning("silenced"); +setTimeout(() => console.log("after tick", seen.length), 0); From 8a02efaa91ef8aea22014ab8067e5c67388863ce Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:28:55 -0700 Subject: [PATCH 02/11] Type process.getBuiltinModule results as module namespaces Under @types/node, process.getBuiltinModule("node:os") types as the module namespace, which lowered as an unchecked dynamic value and failed. A string-literal id naming a supported builtin now lowers to the namespace value an `import * as` binding has, including pi's cast-and-probe spelling `(process as P).getBuiltinModule?.("node:os") ?? null`; a nullable namespace binding narrowed present keys the same member tables, and a namespace is truthy. process.features read as a whole value (`"sea" in process.features`) is an object holding none of the flags, matching the existing member reads. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 2 + .../src/frontend/lowering/builtins/process.ts | 39 +++++++++++++++++++ .../src/frontend/lowering/lower-calls.ts | 14 ++++++- .../compiler/src/frontend/lowering/lowerer.ts | 12 ++++++ .../main.ts.json | 6 +++ .../6232-process-builtin-module-typed/main.ts | 28 +++++++++++++ .../package.json | 1 + .../tsconfig.json | 7 ++++ 8 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json create mode 100644 tests/corpus/6232-process-builtin-module-typed/main.ts create mode 100644 tests/corpus/6232-process-builtin-module-typed/package.json create mode 100644 tests/corpus/6232-process-builtin-module-typed/tsconfig.json diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 26c3b840ee..55566f2a64 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5756,6 +5756,8 @@ export class LlEmitter { // Process streams use a numeric descriptor internally, including zero // for stdin. They remain JavaScript objects in boolean contexts. case "procStream": + // A module namespace object is always present. + case "moduleNs": return "true"; case "array": case "record": diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 94c2cdba72..777ec50e72 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 { canonicalBuiltinModule } from "../../builtin-modules.js"; import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js"; import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js"; @@ -364,6 +365,11 @@ export function lowerProcessProperty( if (member === "versions") { return { kind: "libCall", fn: "process.versions", args: [], type: DYN, loc }; } + // process.features as a value (`"sea" in process.features`): an object + // holding none of the flags, consistent with the member reads above. + if (member === "features") { + return { kind: "dynObjLit", type: DYN, loc }; + } // process.noDeprecation / process.throwDeprecation: the values the // program assigned (undefined before that — no Node flags reach a // compiled binary). @@ -1521,3 +1527,36 @@ function processCredentialCall(member: string, loc: SrcLoc): IrExpr { loc, }; } + +/** `process.getBuiltinModule("node:os")`: a string-literal id naming a + * supported builtin whose call the checker types as that module's + * namespace (@types/node's BuiltInModule map) answers the namespace value + * `import * as ns` binds, so its members key the builtin tables at their + * use sites. The receiver may be cast (`(process as P)`) and the call + * optional: Node 24's process always has the member, and the cast changes + * no runtime value. Other ids keep the dynamic loader (undefined for + * unknown ids, Node's argument errors). */ +export function lowerProcessBuiltinModuleNamespace( + lowerer: Lowerer, + call: ts.CallExpression, +): IrExpr | null { + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "getBuiltinModule") + return null; + const id = call.arguments[0]; + if (call.arguments.length !== 1 || !id || !ts.isStringLiteralLike(id)) return null; + let receiver: ts.Expression = callee.expression; + while ( + ts.isParenthesizedExpression(receiver) || + ts.isAsExpression(receiver) || + ts.isSatisfiesExpression(receiver) || + ts.isNonNullExpression(receiver) + ) + receiver = receiver.expression; + if (!lowerer.isStdlibGlobal(receiver, "process")) return null; + const module = canonicalBuiltinModule(id.text); + if (module === null) return null; + const type = lowerer.mapTypeOf(lowerer.checker.getNonNullableType(lowerer.typeOf(call))); + if (type?.kind !== "moduleNs" || type.moduleId !== `builtin:${module}`) return null; + return { kind: "moduleNsRef", moduleId: type.moduleId, type, loc: locOf(call) }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 2037ba17c1..cebad80536 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -126,7 +126,10 @@ import { lowerPerfHooksCall, perfEntryTag, } from "./builtins/performance.js"; -import { lowerProcStreamMethodCall } from "./builtins/process.js"; +import { + lowerProcStreamMethodCall, + lowerProcessBuiltinModuleNamespace, +} from "./builtins/process.js"; import { recordMayKeepSource, throughRecordSource } from "./record-sources.js"; import { lowerReflectCall } from "./builtins/reflection.js"; import { @@ -363,6 +366,15 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { return { kind: "libCall", fn: "http2.streamNoop", args: [], type: VOID, loc }; } + // `process.getBuiltinModule("node:os")` typed as the module namespace, + // including pi's cast-and-probe spelling + // `(process as P).getBuiltinModule?.("node:os")` (the member always + // exists on Node 24's process object). + { + const namespace = lowerProcessBuiltinModuleNamespace(lowerer, expr); + if (namespace) return namespace; + } + // Optional-chain call forms: `f?.()` (the token on the call) and // `a?.m()` (the token on the member access). The handled markers keep // the chain lowering's re-entrant dispatch from looping. diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 94d526831e..697569bc25 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -11569,6 +11569,18 @@ export class Lowerer { if (mapped?.kind === "moduleNs" && mapped.moduleId.startsWith("builtin:")) { return mapped.moduleId.slice("builtin:".length); } + // A nullable namespace binding (`const os: typeof NodeOs | null`, a + // probed process.getBuiltinModule result) read where the checker + // narrowed it present: its members key the same tables. + if (stored?.kind === "union") { + const present = this.unions.get(stored.unionId)?.arms.filter((arm) => !isUnitType(arm)); + const only = present?.length === 1 ? present[0] : undefined; + if (only?.kind === "moduleNs" && only.moduleId.startsWith("builtin:")) { + const narrowed = this.mapTypeOf(this.typeOf(expr)); + if (narrowed?.kind === "moduleNs" && narrowed.moduleId === only.moduleId) + return only.moduleId.slice("builtin:".length); + } + } if (ts.isIdentifier(expr)) { const symbol = this.checker.getSymbolAtLocation(expr); const decl = symbol ? this.checker.declarationsOf(symbol)[0] : undefined; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json new file mode 100644 index 0000000000..8adab38b7b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6232-process-builtin-module-typed/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6232-process-builtin-module-typed/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6232-process-builtin-module-typed/main.ts b/tests/corpus/6232-process-builtin-module-typed/main.ts new file mode 100644 index 0000000000..7765bd228a --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/main.ts @@ -0,0 +1,28 @@ +// process.getBuiltinModule and process.features under @types/node: a +// literal id types as the module namespace, so the result is the same +// object `import * as os` binds (pi's user-agent probe casts process and +// calls the member optionally), and process.features is an object a +// feature probe can test with `in` (pi's single-executable check). +import * as os from "node:os"; +import type * as NodeOs from "node:os"; + +const direct = process.getBuiltinModule("node:os"); +console.log("direct", direct.platform() === os.platform(), direct.EOL === os.EOL); +console.log("bare id", process.getBuiltinModule("os").arch() === os.arch()); + +type ProcessWithOsBuiltinModule = typeof process & { + getBuiltinModule?: (id: "node:os") => typeof NodeOs; +}; +function loadNodeOs(): typeof NodeOs | null { + if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) { + return null; + } + return (process as ProcessWithOsBuiltinModule).getBuiltinModule?.("node:os") ?? null; +} +const nodeOs = loadNodeOs(); +const agent = nodeOs ? `pi (${nodeOs.platform()} ${nodeOs.release()}; ${nodeOs.arch()})` : "pi (browser)"; +console.log("agent", agent === `pi (${os.platform()} ${os.release()}; ${os.arch()})`); +console.log("present", nodeOs !== null, nodeOs ? typeof nodeOs.homedir() : "none"); + +const isNodeSeaBinary = "sea" in process.features && process.features.sea === true; +console.log("sea", isNodeSeaBinary, "sea" in process.features); diff --git a/tests/corpus/6232-process-builtin-module-typed/package.json b/tests/corpus/6232-process-builtin-module-typed/package.json new file mode 100644 index 0000000000..5ffd9800b9 --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/6232-process-builtin-module-typed/tsconfig.json b/tests/corpus/6232-process-builtin-module-typed/tsconfig.json new file mode 100644 index 0000000000..ae4eda1945 --- /dev/null +++ b/tests/corpus/6232-process-builtin-module-typed/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true + } +} From 0bb9b750fe8b0e9609a18bd44a47008de8481f1b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:39:22 -0700 Subject: [PATCH 03/11] Iterate class instances whose async iterator is an async generator pi-ai consumes every provider stream with `for await` over an EventStream whose `async *[Symbol.asyncIterator](): AsyncIterator` is an async generator method; that was SC1070. The loop now opens the iterator with the method call and drives the returned async generator like any other (break and throw close it). An `async *` declaration annotated with the protocol interfaces (AsyncIterator, AsyncIterableIterator, AsyncIterable) maps like AsyncGenerator. Boxing a class instance whose fields hold a promise now links the dynamic async unit (the materializer boxes the promise), which EventStream's own `this.isComplete(event)` calls need. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/function-returns.ts | 7 + .../lowering/lower-async-iteration.ts | 39 +++- .../src/frontend/lowering/lower-stmts.ts | 6 +- packages/compiler/src/frontend/type-mapper.ts | 26 +++ packages/compiler/src/ir/runtime-features.ts | 30 +++- ...33-for-await-class-async-generator.ts.json | 6 + .../6233-for-await-class-async-generator.ts | 168 ++++++++++++++++++ 7 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json create mode 100644 tests/corpus/6233-for-await-class-async-generator.ts diff --git a/packages/compiler/src/frontend/lowering/function-returns.ts b/packages/compiler/src/frontend/lowering/function-returns.ts index 5ba63be26d..e72504c3d5 100644 --- a/packages/compiler/src/frontend/lowering/function-returns.ts +++ b/packages/compiler/src/frontend/lowering/function-returns.ts @@ -1,3 +1,4 @@ +import { mapAsyncGeneratorAnnotation } from "../type-mapper.js"; import { dynUndefinedExpr } from "../../ir/build.js"; import * as ts from "../ts7/adapter.js"; import type { Lowerer } from "./lowerer.js"; @@ -146,6 +147,12 @@ export function declaredReturnType( // (`.action(() => { throw ... })`). `never` VALUES stay unmapped. if (retTsType.flags & ts.TypeFlags.Never) return VOID; let mappedReturn = lowerer.mapTypeOf(retTsType); + if ( + mappedReturn === null && + decl.asteriskToken && + decl.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) + ) + mappedReturn = mapAsyncGeneratorAnnotation(retTsType, lowerer.typeCtx); if ( isJsSourceFile(decl.getSourceFile()) && decl.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) && diff --git a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts index 1bd074dd75..053d942013 100644 --- a/packages/compiler/src/frontend/lowering/lower-async-iteration.ts +++ b/packages/compiler/src/frontend/lowering/lower-async-iteration.ts @@ -20,7 +20,7 @@ import { VOID, } from "../../ir/ir.js"; import { locOf } from "../program.js"; -import { extractIteratorValue } from "./lower-generators.js"; +import { extractIteratorValue, type GenType } from "./lower-generators.js"; import { streamSidesOf } from "./lower-stream.js"; import { staticReadableStreamElementType } from "./lower-stdlib.js"; import { genResultRecord } from "../type-mapper.js"; @@ -282,6 +282,43 @@ function classAsyncIteratorPlan( }; } +/** `for await (const x of obj)` where obj's class declares (or inherits) + * `async *[Symbol.asyncIterator]()`: the loop opens the iterator with that + * method call, evaluated once, and then drives the async generator it + * returns exactly like `for await (const x of obj[Symbol.asyncIterator]())` + * (pi's EventStream). Null for other sources. */ +export function classAsyncGeneratorIterator( + lowerer: Lowerer, + expression: ts.Expression, +): (IrExpr & { type: GenType }) | null { + const sourceType = lowerer.mapTypeOf(lowerer.typeOf(expression)); + if (sourceType?.kind !== "object") return null; + const info = lowerer.classes.get(sourceType.className); + const open = info ? lowerer.findMethodOn(info, "sym:asyncIterator") : null; + if ( + !open || + open.sig.abstract === true || + open.sig.params.length !== 0 || + open.sig.ret.kind !== "generator" || + !open.sig.ret.async + ) + return null; + const retT = open.sig.ret; + const source = lowerer.lowerExpr(expression); + if (source.type.kind !== "object") return null; + return { + ...lowerer.accessorCall( + sourceType.className, + "sym:asyncIterator", + source, + [], + retT, + locOf(expression), + ), + type: retT, + }; +} + export function checkForAwaitBinding( lowerer: Lowerer, stmt: ts.ForOfStatement, diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index b7f0c2481f..784036fddd 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -28,7 +28,7 @@ import { lowerYieldStarStatement, type GenType, } from "./lower-generators.js"; -import { lowerForAwaitBuiltin } from "./lower-async-iteration.js"; +import { classAsyncGeneratorIterator, lowerForAwaitBuiltin } from "./lower-async-iteration.js"; import { BOOL, BYTES_U8, @@ -8281,6 +8281,10 @@ export function lowerForOf(lowerer: Lowerer, stmt: ts.ForOfStatement): IrStmt { } } } + { + const opened = classAsyncGeneratorIterator(lowerer, stmt.expression); + if (opened) return lowerForAwaitGenerator(lowerer, stmt, opened, labels); + } if ( ts.isPropertyAccessExpression(stmt.expression) && lowerer.stdlibGlobalMember(stmt.expression, "process") === "stdin" diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index f37703fd8e..191ad670df 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -1313,6 +1313,32 @@ export function isWorkerHandleType( return handle; } +/** The return annotation of an `async *` declaration spelled with the + * protocol interfaces the generator object implements — + * `AsyncIterator`, `AsyncIterableIterator` or `AsyncIterable` (pi's + * `async *[Symbol.asyncIterator](): AsyncIterator`): the body always + * produces an async generator, so the declaration maps exactly like an + * `AsyncGenerator` annotation. Values of these types + * elsewhere stay unmapped (they can hold any object implementing the + * protocol). Null when the annotation is not one of them. */ +export function mapAsyncGeneratorAnnotation(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const { checker } = ctx; + const symbol = type.getSymbol(); + if ( + !symbol || + !["AsyncIterator", "AsyncIterableIterator", "AsyncIterable"].includes(symbol.name) || + !checker + .declarationsOf(symbol) + .some((d) => ts.isInterfaceDeclaration(d) && ctx.isStdlibFile(d.getSourceFile())) + ) + return null; + const args = checker.getTypeArguments(type as ts.TypeReference); + const channels = genChannels(args[0], args[1], args[2], ctx); + if (!channels) return null; + if (!genResultRecord(channels.yieldT, channels.retT, ctx.shapes, ctx.unions)) return null; + return { kind: "generator", async: true, ...channels }; +} + export function mapType(type: ts.Type, ctx: TypeMapperCtx): IrType | null { if (mapTypeDepth >= MAP_TYPE_MAX_DEPTH) return null; // These intrinsic domains cannot depend on generic bindings, declaration diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 72f30a76e9..d6eb1b1cb6 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -1,4 +1,4 @@ -import type { IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; +import type { IrClassDef, IrExpr, IrModule, IrRecordShape, IrType, IrUnionDef } from "./ir.js"; import type { IrLibFn } from "./builtin-signatures.js"; import { isDirentShape, @@ -137,6 +137,7 @@ export function moduleRuntimeFeatures(mod: IrModule): RuntimeFeatures { interface TypeTables { records: Map; unions: Map; + classes: Map; } /** Whether boxing a value of type `t` as dyn boxes a promise, which goes @@ -167,6 +168,32 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean const def = tables.unions.get(t.unionId); return def?.arms.some((a) => boxesPromise(a, tables, seen)) ?? false; } + case "object": { + // A boxed class instance materializes its fields, inherited ones + // included, and the instance can be any subclass. + const key = `class:${t.className}`; + if (seen.has(key)) return false; + seen.add(key); + const related = (def: IrClassDef): boolean => { + for ( + let c: IrClassDef | undefined = def; + c; + c = c.base ? tables.classes.get(c.base) : undefined + ) + if (c.name === t.className) return true; + return false; + }; + for (const def of tables.classes.values()) { + if (!related(def)) continue; + for ( + let c: IrClassDef | undefined = def; + c; + c = c.base ? tables.classes.get(c.base) : undefined + ) + if (c.fields.some((f) => boxesPromise(f.type, tables, seen))) return true; + } + return false; + } default: return false; } @@ -223,6 +250,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run (tables ??= { records: new Map((mod.records ?? []).map((r) => [r.id, r])), unions: new Map((mod.unions ?? []).map((u) => [u.id, u])), + classes: new Map((mod.classes ?? []).map((c) => [c.name, c])), }); const expr = (node: IrExpr): boolean => { if (node.kind === "libCall") { diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json new file mode 100644 index 0000000000..89f131b0fe --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6233-for-await-class-async-generator.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6233-for-await-class-async-generator.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6233-for-await-class-async-generator.ts b/tests/corpus/6233-for-await-class-async-generator.ts new file mode 100644 index 0000000000..1a88df7e87 --- /dev/null +++ b/tests/corpus/6233-for-await-class-async-generator.ts @@ -0,0 +1,168 @@ +// `for await` over a class instance whose [Symbol.asyncIterator] is an +// async generator method — pi-ai's EventStream, which is how every +// provider stream is consumed: a generic push/end queue, a subclass, the +// protocol-interface return annotations, producers that run between +// iterations, early exit (the generator's finally runs), and errors. +class FifoQueue { + private incoming: T[] = []; + private outgoing: T[] = []; + + get length(): number { + return this.incoming.length + this.outgoing.length; + } + + enqueue(value: T): void { + this.incoming.push(value); + } + + dequeue(): T | undefined { + if (this.outgoing.length === 0) { + while (this.incoming.length > 0) { + this.outgoing.push(this.incoming.pop()!); + } + } + return this.outgoing.pop(); + } +} + +class EventStream implements AsyncIterable { + private queue = new FifoQueue(); + private waiting = new FifoQueue<(value: IteratorResult) => void>(); + protected done = false; + private finalResultPromise: Promise; + private resolveFinalResult!: (result: R) => void; + private isComplete: (event: T) => boolean; + private extractResult: (event: T) => R; + + constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) { + this.isComplete = isComplete; + this.extractResult = extractResult; + this.finalResultPromise = new Promise((resolve) => { + this.resolveFinalResult = resolve; + }); + } + + push(event: T): void { + if (this.done) return; + if (this.isComplete(event)) { + this.done = true; + this.resolveFinalResult(this.extractResult(event)); + } + const waiter = this.waiting.dequeue(); + if (waiter) { + waiter({ value: event, done: false }); + } else { + this.queue.enqueue(event); + } + } + + end(result?: R): void { + this.done = true; + if (result !== undefined) { + this.resolveFinalResult(result); + } + while (this.waiting.length > 0) { + const waiter = this.waiting.dequeue()!; + waiter({ value: undefined as any, done: true }); + } + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.dequeue()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => this.waiting.enqueue(resolve)); + if (result.done) return; + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +type Event = { type: "text"; delta: string } | { type: "done"; text: string }; + +class TextStream extends EventStream { + constructor() { + super( + (event) => event.type === "done", + (event) => (event.type === "done" ? event.text : ""), + ); + } +} + +function produce(stream: TextStream, words: string[]): void { + let i = 0; + const step = (): void => { + if (i < words.length) { + stream.push({ type: "text", delta: words[i]! }); + i++; + setTimeout(step, 1); + } else { + stream.push({ type: "done", text: words.join("") }); + } + }; + step(); +} + +class Ticker { + log: string[] = []; + async *[Symbol.asyncIterator](): AsyncIterableIterator { + try { + for (let n = 1; n <= 5; n++) { + this.log.push(`yield ${n}`); + yield n; + } + } finally { + this.log.push("closed"); + } + } +} + +class Failing { + async *[Symbol.asyncIterator](): AsyncGenerator { + yield "first"; + throw new Error("stream failed"); + } +} + +async function main(): Promise { + const stream = new TextStream(); + produce(stream, ["Hello", " from", " the", " stub"]); + let text = ""; + for await (const event of stream) { + if (event.type === "text") text += event.delta; + else console.log("done event", event.text); + } + console.log("streamed", JSON.stringify(text), "result", await stream.result()); + + // Events queued before iteration starts, then end() with no consumer waiting. + const early = new TextStream(); + early.push({ type: "text", delta: "a" }); + early.push({ type: "text", delta: "b" }); + early.end("ab"); + const seen: string[] = []; + for await (const event of early) seen.push(event.type === "text" ? event.delta : "?"); + console.log("queued", seen.join(","), await early.result()); + + // break closes the generator: its finally runs before the loop exits. + const ticker = new Ticker(); + for await (const n of ticker) { + if (n === 3) break; + } + console.log("ticker", ticker.log.join(" | ")); + + // A throw from the generator rejects the loop. + try { + for await (const s of new Failing()) console.log("got", s); + } catch (e) { + console.log("caught", (e as Error).message); + } +} +main(); From 71b99913a24b1cf3747b15c738eeec2e35b3b936 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:45:07 -0700 Subject: [PATCH 04/11] Allow WeakMap- and WeakSet-typed class fields Weak collections already lower to checked-dynamic storage as locals, but a class field of those types was fenced as a dynamic-typed field (pi's AgentSession keys entry ids by message in a WeakMap field). Declared, optional, and constructor-assigned fields of those types now store the native collection like the locals do. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/frontend/lowering/lowerer.ts | 30 ++++++++++++-- .../6234-weak-collection-class-fields.ts.json | 6 +++ .../6234-weak-collection-class-fields.ts | 39 +++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json create mode 100644 tests/corpus/6234-weak-collection-class-fields.ts diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 697569bc25..5804502b74 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3002,13 +3002,37 @@ export class Lowerer { /** Declared class fields may store a DYN value only when its static type * is `unknown` or `object` (also with `null` or `undefined`, or an array - * of those), names checked protocol identity, or is an owned runtime - * handle (a Worker or MessagePort); `any` fields stay refused. */ + * of those), names checked protocol identity, is an owned runtime + * handle (a Worker or MessagePort), or is a WeakMap/WeakSet (native weak + * collections live in checked-dynamic storage, as locals of those types + * do); `any` fields stay refused. */ classFieldStoresDyn(type: ts.Type): boolean { return ( this.opaqueValueType(type) || this.classImplementedProtocol(type) || - isWorkerHandleType(type, this.typeCtx) + isWorkerHandleType(type, this.typeCtx) || + this.weakCollectionType(type) + ); + } + + /** A stdlib WeakMap or WeakSet, alone or with `null`/`undefined`. */ + private weakCollectionType(type: ts.Type): boolean { + const weak = (part: ts.Type): boolean => { + const symbol = part.getSymbol(); + return ( + (symbol?.name === "WeakMap" || symbol?.name === "WeakSet") && + this.checker + .declarationsOf(symbol) + .some((d) => ts.isInterfaceDeclaration(d) && this.isStdlibFile(d.getSourceFile())) + ); + }; + if (!type.isUnionType()) return weak(type); + const parts = ts.constituentTypes(type); + return ( + parts.some(weak) && + parts.every( + (part) => (part.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0 || weak(part), + ) ); } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json new file mode 100644 index 0000000000..e7e18883aa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6234-weak-collection-class-fields.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6234-weak-collection-class-fields.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6234-weak-collection-class-fields.ts b/tests/corpus/6234-weak-collection-class-fields.ts new file mode 100644 index 0000000000..d7a0859212 --- /dev/null +++ b/tests/corpus/6234-weak-collection-class-fields.ts @@ -0,0 +1,39 @@ +// WeakMap- and WeakSet-typed class fields (pi's AgentSession keys entry ids by +// message object): initialized, optional, and constructor-assigned fields hold +// the native weak collections like locals of those types. +class Session { + private readonly _entryIdsByMessage = new WeakMap(); + private seen = new WeakSet(); + cache: WeakMap | undefined; + record(message: object, id: string): void { + this._entryIdsByMessage.set(message, id); + this.seen.add(message); + } + idOf(message: object): string | undefined { + return this._entryIdsByMessage.get(message); + } + has(message: object): boolean { + return this.seen.has(message) && this._entryIdsByMessage.has(message); + } +} +const s = new Session(); +const a = { role: "user" }; +const b = { role: "assistant" }; +s.record(a, "e1"); +console.log(s.idOf(a), s.idOf(b), s.has(a), s.has(b)); +s.cache = new WeakMap(); +s.cache.set(a, 1); +console.log(s.cache.get(a), s.cache.has(b), s.cache instanceof WeakMap); +class Registry { + private readonly names: WeakMap; + constructor(names: WeakMap) { + this.names = names; + } + name(o: object): string { + return this.names.get(o) ?? "(unnamed)"; + } +} +const names = new WeakMap(); +names.set(a, "alice"); +const registry = new Registry(names); +console.log(registry.name(a), registry.name(b)); From bcc35ddf1a29adbe62950483fa0fb477813cdf70 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 13:52:59 -0700 Subject: [PATCH 05/11] Call chalk.bold from TypeScript #1309 let a checker-untyped receiver call a member named like a String.prototype-only method (`bold`, `italics`, ...), refusing only a string receiver at run time. TypeScript importing chalk's JavaScript source types the default export as a plain function, so `chalk.bold` is an `any` member on a typed receiver and still fenced; pi calls it in nine places. A checker-untyped member now takes the same path. The chalk 5 fixture pins root, aliased, and Chalk-instance `bold` calls. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/lowering/lower-calls.ts | 8 +++++++- tests/fixtures/npm/cases/chalk5/main.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index cebad80536..44b08dbb0a 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -3497,8 +3497,14 @@ function lowerDynReceiverMethodCall( // own or inherited member exactly like a non-prototype name (chalk's // `chalk.red.bold(...)` reads a prototype getter of a function), so // only a string receiver keeps the refusal, deferred to run time. + // The same holds when only the MEMBER is checker-untyped: TypeScript + // importing chalk's JavaScript source types the default export as a + // plain function, so `chalk.bold` reads as `any` on a typed receiver. const stringOnlyName = - checkerUntyped && !ownCallable && DYN_STRING_PROTO_ONLY_NAMES.has(access.name.text); + (checkerUntyped || + (lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) && + !ownCallable && + DYN_STRING_PROTO_ONLY_NAMES.has(access.name.text); if (DYN_PROTO_METHOD_NAMES.has(access.name.text) && !ownCallable && !stringOnlyName) return null; // Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain // machinery's short-circuit semantics — not modeled here yet. diff --git a/tests/fixtures/npm/cases/chalk5/main.ts b/tests/fixtures/npm/cases/chalk5/main.ts index 6a33a1466d..0bfa806c16 100644 --- a/tests/fixtures/npm/cases/chalk5/main.ts +++ b/tests/fixtures/npm/cases/chalk5/main.ts @@ -15,6 +15,13 @@ console.log(modifierNames.join(",")); console.log(foregroundColorNames.length + " " + backgroundColorNames.length + " " + colorNames.length); show("red", chalk.red("red")); +// Root members named like String.prototype's HTML methods, called from +// TypeScript (pi's `chalk.bold(APP_NAME)`): the checker types chalk's +// JavaScript default export as a plain function, so the member is `any`. +show("bold", chalk.bold("bold")); +show("bold template", `${chalk.bold("Usage:")} pi`); +const alias = chalk; +show("bold alias", alias.bold("alias") + alias.italic("i")); show("red.bold", chalk.red.bold("bold red")); show("nested", chalk.blue("a " + chalk.red("b") + " c")); show("multi-arg", chalk.green("one", "two", 3)); @@ -36,6 +43,7 @@ show("gray-grey", chalk.gray("x") + chalk.grey("y")); for (const level of [0, 1, 2, 3] as const) { const c = new Chalk({ level }); show(`L${level} red.bold`, c.red.bold("x")); + show(`L${level} bold`, c.bold("x")); show(`L${level} hex`, c.hex("#123456")("x")); show(`L${level} rgb`, c.rgb(255, 255, 255).bgRgb(0, 0, 0)("x")); show(`L${level} ansi256`, c.ansi256(100).bgAnsi256(200)("x")); From 98b3ced80c5ed09b61b1413086c09e490d5aeeaf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:00:41 -0700 Subject: [PATCH 06/11] Resolve bare core-module names to the builtin over npm packages Node checks its builtins before node_modules for a bare core-module name, so an installed npm package named string_decoder (a transitive dependency of pi) never answers `import ... from "string_decoder"`. scriptc attributed the import to the package and refused it. The npm resolution now answers null for bare builtin names (prefix-only builtins such as node:test keep their bare names for packages), for ES imports and CommonJS requires alike. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/program.ts | 6 ++++++ .../main.ts.json | 7 +++++++ .../helper.cjs | 6 ++++++ .../6235-bare-builtin-shadowed-package/main.ts | 17 +++++++++++++++++ .../node_modules/events/events.js | 1 + .../node_modules/events/package.json | 1 + .../node_modules/string_decoder/index.js | 3 +++ .../node_modules/string_decoder/package.json | 1 + .../package.json | 1 + 9 files changed, 43 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/main.ts create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json create mode 100644 tests/corpus/6235-bare-builtin-shadowed-package/package.json diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index df0cb673e2..cf5287430d 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -3971,6 +3971,12 @@ function resolveNpmImport7( if (isRelativeSpecifier(specifier) || specifier.startsWith("node:")) { return null; } + // Node resolves a bare core-module name (`string_decoder`, `events`, + // `fs/promises`) to the builtin before it looks in node_modules, so an + // installed npm package of the same name never answers it. Prefix-only + // builtins (`node:test`) are listed with their prefix, so a bare `test` + // still names the package. + if (nodeBuiltinNames.has(specifier)) return null; // --provenance-sources: a registered specifier is NOT an npm import — // its attested source compiles as program modules (resolveProjectModule diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json new file mode 100644 index 0000000000..eee3db81ad --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6235-bare-builtin-shadowed-package/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs", + "/tests/corpus/6235-bare-builtin-shadowed-package/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs b/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs new file mode 100644 index 0000000000..8e0a0457cc --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/helper.cjs @@ -0,0 +1,6 @@ +// A CommonJS require of the bare names resolves to the builtins too. +const { StringDecoder } = require("string_decoder"); +exports.decodeEuro = () => { + const d = new StringDecoder("utf8"); + return d.write(Buffer.from([0xe2, 0x82])) + d.end(Buffer.from([0xac])); +}; diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/main.ts b/tests/corpus/6235-bare-builtin-shadowed-package/main.ts new file mode 100644 index 0000000000..e9e83ea872 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/main.ts @@ -0,0 +1,17 @@ +// A bare core-module specifier names the Node builtin even when an npm +// package of the same name is installed (pi depends on string_decoder +// transitively): Node checks its builtins before node_modules. +import { StringDecoder } from "string_decoder"; +import { EventEmitter } from "events"; +import { createRequire } from "node:module"; + +const decoder = new StringDecoder("utf8"); +console.log(decoder.write(Buffer.from([0xe2, 0x82])) + decoder.end(Buffer.from([0xac]))); + +const emitter = new EventEmitter(); +emitter.on("ping", (n: number) => console.log("ping", n)); +emitter.emit("ping", 1); + +const require = createRequire(import.meta.url); +const helper = require("./helper.cjs") as { decodeEuro(): string }; +console.log("cjs", helper.decodeEuro()); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js new file mode 100644 index 0000000000..f1a344ef11 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/events.js @@ -0,0 +1 @@ +throw new Error("the npm events package was loaded"); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json new file mode 100644 index 0000000000..0fb1a3c8aa --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/events/package.json @@ -0,0 +1 @@ +{ "name": "events", "version": "3.3.0", "main": "events.js" } diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js new file mode 100644 index 0000000000..02af46d5ac --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/index.js @@ -0,0 +1,3 @@ +// An installed package named like the core module: Node never loads it +// for the bare specifier. +throw new Error("the npm string_decoder package was loaded"); diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json new file mode 100644 index 0000000000..ad07adde83 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/node_modules/string_decoder/package.json @@ -0,0 +1 @@ +{ "name": "string_decoder", "version": "1.3.0", "main": "index.js" } diff --git a/tests/corpus/6235-bare-builtin-shadowed-package/package.json b/tests/corpus/6235-bare-builtin-shadowed-package/package.json new file mode 100644 index 0000000000..5ffd9800b9 --- /dev/null +++ b/tests/corpus/6235-bare-builtin-shadowed-package/package.json @@ -0,0 +1 @@ +{ "type": "module" } From 50235c42c50443e4dfd70691b81f03746d9a79d4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:12:48 -0700 Subject: [PATCH 07/11] Support pi-tui's terminal setup on process streams Three gaps on pi-tui's terminal start path: - `if (process.stdin.setRawMode)` read the method as a value (SC2020). The read now answers the stream object's member: the method on a TTY, undefined otherwise, as on Node. - process.stdin.setEncoding had no runtime method, and a string 'data' listener was fenced. stdin now decodes with the shared StringDecoder once per read (split multibyte sequences stay whole, end() flushes the tail before 'end'), string listeners receive the text, and dynamic listeners receive what Node delivers. A string listener with no encoding set throws the setEncoding-state TypeError, as child streams do. - process.stdout.on("resize", cb) (once/off/removeListener, stderr too) registers on the stream object, which listens for SIGWINCH and emits when a TTY's size changed; the listener never keeps the loop alive. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/lib-process.ts | 13 +- .../src/frontend/lowering/builtins/process.ts | 90 +++++++- packages/compiler/src/ir/validate.ts | 3 +- .../main.ts.json | 6 + packages/runtime/src/scr_events.c | 217 +++++++++++++++++- packages/runtime/src/scr_events.h | 7 + .../6236-stdin-encoding-tty-probes/main.ts | 62 +++++ .../package.json | 1 + .../tsconfig.json | 7 + 9 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/main.ts create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/package.json create mode 100644 tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 546c9a209a..026727d9f8 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -544,15 +544,24 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll B.line(`call void @scr_stdin_on_end(ptr ${cb.name}, i1 ${once.name})`); return { name: "", type: e.type }; } + // A string listener takes the setEncoding-decoded chunk. + const stringData = e.fn === "stdin.onData" && cbT.params[0]?.kind === "string"; const adapter = e.fn === "stdin.onData" ? cbT.params.length === 0 ? "scr_stdin_data_thunk0" - : "scr_stdin_data_thunk_bytes" + : stringData + ? "scr_stdin_data_thunk_str" + : "scr_stdin_data_thunk_bytes" : cbT.params.length === 0 ? "scr_child_err_thunk0" : "scr_child_err_thunk_error"; - const sym = e.fn === "stdin.onData" ? "scr_stdin_on_data" : "scr_stdin_on_error"; + const sym = + e.fn === "stdin.onData" + ? stringData + ? "scr_stdin_on_data_str" + : "scr_stdin_on_data" + : "scr_stdin_on_error"; host.declare(`declare void @${adapter}(ptr, ptr)`); host.declare(`declare void @${sym}(ptr, ptr, i1 zeroext)`); B.line(`call void @${sym}(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`); diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 777ec50e72..fa3cf1703f 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -149,6 +149,18 @@ export function lowerProcessStreamProperty( loc: locOf(expr), }; } + // `if (process.stdin.setRawMode)`: Node's stdin has the method only + // when it is a TTY. The runtime's stream object answers the same + // presence (the method or undefined); calls keep their direct lowering. + if (direct === "stdin" && member === "setRawMode") { + return { + kind: "dynKeyGet", + value: lowerer.lowerExpr(expr.expression), + key: strLit(member, locOf(expr.name)), + type: DYN, + loc: locOf(expr), + }; + } if (member !== "isTTY" && member !== "columns" && member !== "rows") return null; let recv: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv)) @@ -633,6 +645,73 @@ export function lowerProcessMethodCall( } return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc }; } + // process.stdout.on("resize", cb) (and once/off/removeListener/ + // addListener, stderr too): the terminal resize event, registered on the + // runtime's process stream object (a SIGWINCH listener that emits when a + // TTY's size changed; it never keeps the loop alive). Statement position: + // Node returns the stream. + if ( + ["on", "once", "off", "removeListener", "addListener"].includes(access.name.text) && + ts.isPropertyAccessExpression(access.expression) && + call.arguments.length === 2 && + !call.arguments.some(ts.isSpreadElement) && + ts.isExpressionStatement(call.parent) + ) { + const stream = lowerer.stdlibGlobalMember(access.expression, "process"); + const evT = lowerer.typeOf(call.arguments[0]!); + if ( + (stream === "stdout" || stream === "stderr") && + evT.isStringLiteralType() && + evT.value === "resize" + ) { + const loc = locOf(call); + const receiverLocal = lowerer.declareHiddenLocal("%stdioReceiver", DYN); + const receiver = varRef(receiverLocal.id, DYN, loc); + const method = + access.name.text === "off" + ? "removeListener" + : access.name.text === "addListener" + ? "on" + : access.name.text; + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: receiverLocal.id, + init: { + kind: "libCall", + fn: "process.stdio", + args: [numLit(stream === "stdout" ? 1 : 2, loc)], + type: DYN, + loc, + }, + loc, + }, + ], + result: { + kind: "dynCall", + callee: { + kind: "dynKeyGet", + value: receiver, + key: strLit(method, locOf(access.name)), + type: DYN, + loc, + }, + receiver, + calleeName: access.getText(), + args: [ + lowerer.lowerExprExpecting(call.arguments[0]!, DYN), + lowerer.lowerExprExpecting(call.arguments[1]!, DYN), + ], + type: DYN, + loc, + }, + type: DYN, + loc, + }; + } + } // process.stdin.on/once("data" | "end" | "error", cb): the piped-stdin // event slice. A 'data' listener keeps the event loop alive until EOF // (Node's flowing stdin); 'end'/'error' listeners alone do not. `once` @@ -681,11 +760,18 @@ export function lowerProcessMethodCall( const param = cb.type.params[0]; const onceArg: IrExpr = { kind: "boolLit", value: once, type: BOOL, loc }; if (event === "data") { - if (param !== undefined && !(param.kind === "bytes" && param.elem === "u8")) { + // A string parameter receives the chunks process.stdin.setEncoding + // decodes (pi-tui's terminal input); the runtime refuses a delivery + // while no encoding is set, where Node would pass a Buffer. + if ( + param !== undefined && + !(param.kind === "bytes" && param.elem === "u8") && + param.kind !== "string" + ) { lowerer.unsupported( "SC1090", call.arguments[1]!, - `data listeners whose parameter is not 'Uint8Array' (got '${lowerer.fmt(param)}')`, + `data listeners whose parameter is not 'Uint8Array' or 'string' (got '${lowerer.fmt(param)}')`, ); } return { kind: "libCall", fn: "stdin.onData", args: [cb, onceArg], type: VOID, loc }; diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 09ab7d9519..bb218bc8ed 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -5084,7 +5084,8 @@ function validateFunction( let ok = cbT?.kind === "func" && cbT.ret.kind === "void" && cbT.params.length <= 1; if (ok && cbT?.kind === "func" && cbT.params.length === 1) { const p = cbT.params[0]!; - if (e.fn === "stdin.onData") ok = p.kind === "bytes" && p.elem === "u8"; + if (e.fn === "stdin.onData") + ok = (p.kind === "bytes" && p.elem === "u8") || p.kind === "string"; else if (e.fn === "stdin.onError") ok = p.kind === "object" && p.className === "%Error"; else ok = p.kind === "f64"; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json new file mode 100644 index 0000000000..c7546dd635 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6236-stdin-encoding-tty-probes/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6236-stdin-encoding-tty-probes/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 670ea10d65..1d4b878d47 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -345,9 +345,18 @@ static void scr_signals_drain(void) { * listener it is treated as EOF (piped fd 0 read errors are not * meaningfully reachable). */ +/* What a 'data' listener receives: raw chunks (typed Buffer listeners and + * the runtime's own consumers), the decoded string (typed string + * listeners, which need setEncoding), or whichever Node delivers + * (checked-dynamic listeners: the string once an encoding is set, else + * the Buffer). */ +enum { SCR_STDIN_DATA_BYTES, SCR_STDIN_DATA_STRING, SCR_STDIN_DATA_DYN }; + typedef struct { ScrClosure *cb; /* owned */ void (*fn)(ScrClosure *cb, ScrBytes *chunk); /* adapter: chunk borrowed */ + void (*str_fn)(ScrClosure *cb, ScrStr *text); /* STRING/DYN: text borrowed */ + unsigned char kind; bool once; } ScrStdinDataL; @@ -376,9 +385,16 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; - -void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), - bool once) { +/* process.stdin.setEncoding(enc): the canonical encoding name (NULL until + * set) and the StringDecoder's packed pending partial sequence, so a + * multibyte character split across reads decodes whole, as Node's + * decoder does. */ +static SCR_TL ScrStr *scr_stdin_encoding = NULL; +static SCR_TL double scr_stdin_dec_pending = 0; + +static void scr_stdin_add_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + void (*str_fn)(ScrClosure *, ScrStr *), unsigned char kind, + bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); /* never fires again, like Node post-'end' */ return; @@ -390,10 +406,35 @@ void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrByt } scr_stdin_data[scr_stdin_ndata].cb = cb; scr_stdin_data[scr_stdin_ndata].fn = fn; + scr_stdin_data[scr_stdin_ndata].str_fn = str_fn; + scr_stdin_data[scr_stdin_ndata].kind = kind; scr_stdin_data[scr_stdin_ndata].once = once; scr_stdin_ndata++; } +void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + bool once) { + scr_stdin_add_data(cb, fn, NULL, SCR_STDIN_DATA_BYTES, once); +} + +void scr_stdin_on_data_str(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrStr *), + bool once) { + scr_stdin_add_data(cb, NULL, fn, SCR_STDIN_DATA_STRING, once); +} + +static void scr_stdin_encoding_teardown(void) { + scr_str_release(scr_stdin_encoding); + scr_stdin_encoding = NULL; +} + +void scr_stdin_set_encoding(ScrStr *enc) { + if (scr_stdin_encoding == NULL) scr_atexit(scr_stdin_encoding_teardown); + ScrStr *previous = scr_stdin_encoding; + scr_stdin_encoding = scr_str_retain(enc); + scr_str_release(previous); + scr_stdin_dec_pending = 0; +} + void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { scr_closure_release(cb); @@ -484,10 +525,20 @@ static void scr_stdin_settle_done(void) { scr_promise_release(p); } +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); + /* EOF/teardown shared tail: 'end' fires (snapshot; once semantics are * moot — everything drops right after), the parked promise settles done, * and every listener is released. */ static void scr_stdin_finish(bool fire_end) { + if (fire_end && scr_stdin_encoding != NULL && scr_stdin_ndata > 0) { + /* The decoder's end() flush reaches the string listeners before 'end'. */ + ScrStr *tail = scr_strdec_end(scr_stdin_encoding, scr_stdin_dec_pending); + scr_stdin_dec_pending = 0; + if (tail->len > 0) scr_stdin_deliver_parts(NULL, tail); + scr_str_release(tail); + if (scr_exc_pending()) fire_end = false; + } scr_stdin_eof = true; if (fire_end) { size_t n = scr_stdin_nend; @@ -658,6 +709,20 @@ static void scr_stdin_service(void) { } static void scr_stdin_deliver(ScrBytes *chunk) { + if (scr_stdin_ndata > 0 && scr_stdin_encoding != NULL) { + /* Node's readable decodes once, in the stream: every listener sees + * the same string, and a read holding only a partial sequence emits + * nothing. */ + ScrStr *text = scr_strdec_write(scr_stdin_encoding, scr_stdin_dec_pending, chunk); + scr_stdin_dec_pending = scr_strdec_next(scr_stdin_encoding, scr_stdin_dec_pending, chunk); + if (text->len > 0) scr_stdin_deliver_parts(chunk, text); + scr_str_release(text); + return; + } + scr_stdin_deliver_parts(chunk, NULL); +} + +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { if (scr_stdin_ndata > 0) { size_t nd = scr_stdin_ndata; ScrStdinDataL *snap = malloc(nd * sizeof *snap); @@ -678,11 +743,23 @@ static void scr_stdin_deliver(ScrBytes *chunk) { } } } - if (!scr_exc_pending()) snap[i].fn(snap[i].cb, chunk); + if (!scr_exc_pending()) { + if (snap[i].kind == SCR_STDIN_DATA_BYTES) { + if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); + } else if (text != NULL) { + snap[i].str_fn(snap[i].cb, text); + } else if (snap[i].kind == SCR_STDIN_DATA_DYN) { + if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); + } else { + static const char msg[] = + "stdin data listener type does not match setEncoding state"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + } + } scr_closure_release(snap[i].cb); } free(snap); - } else if (scr_stdin_waiter) { + } else if (scr_stdin_waiter && chunk != NULL) { ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, @@ -701,13 +778,20 @@ typedef struct { bool *error_once; size_t nerrors; ScrDyn *write_error; + /* 'resize' listeners (stdout/stderr) and the size they last saw. */ + ScrDyn **resizes; + bool *resize_once; + size_t nresizes; + double columns, rows; } ScrStdio; static SCR_TL ScrStdio scr_stdio_streams[3] = {{.fd = 0}, {.fd = 1}, {.fd = 2}}; enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, - STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_METHOD_COUNT }; + STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, + STDIO_SET_ENCODING, STDIO_METHOD_COUNT }; static const char *const scr_stdio_names[] = { - "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize" + "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize", + "setEncoding" }; static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; @@ -736,6 +820,11 @@ static void scr_stdio_data(ScrClosure *cb, ScrBytes *bytes) { scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { + ScrDyn *chunk = scr_dyn_new_str(text); + scr_stdio_fire(cb, chunk, 1); + scr_dyn_release(chunk); +} static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); @@ -861,6 +950,93 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { return result; } +/* 'resize' on a TTY stdout/stderr: Node's tty.WriteStream listens for + * SIGWINCH and emits 'resize' when the window size changed. The signal + * listener does not keep the loop alive, and a non-TTY stream never + * emits. */ +static SCR_TL ScrDyn *scr_stdio_winch = NULL; + +static void scr_stdio_resize_emit(ScrStdio *stream) { + size_t n = stream->nresizes; + if (n == 0) return; + ScrDyn **snap = malloc(n * sizeof *snap); + bool *once = malloc(n * sizeof *once); + if (!snap || !once) scr_events_oom(); + for (size_t i = 0; i < n; i++) { + snap[i] = scr_dyn_retain(stream->resizes[i]); + once[i] = stream->resize_once[i]; + } + for (size_t i = 0; i < n; i++) { + if (once[i]) { + for (size_t j = 0; j < stream->nresizes; j++) { + if (stream->resizes[j] == snap[i]) { + scr_dyn_release(stream->resizes[j]); + memmove(stream->resizes + j, stream->resizes + j + 1, (stream->nresizes - j - 1) * sizeof *stream->resizes); + memmove(stream->resize_once + j, stream->resize_once + j + 1, (stream->nresizes - j - 1) * sizeof *stream->resize_once); + stream->nresizes--; + break; + } + } + } + if (!scr_exc_pending()) { + scr_dyn_this_push(stream, SCR_DYNH_STDIO); + ScrDyn *r = scr_dyn_call(snap[i], NULL, 0, "listener"); + scr_dyn_this_pop(); + scr_dyn_release(r); + } + scr_dyn_release(snap[i]); + } + free(snap); + free(once); +} + +static ScrDyn *scr_stdio_winch_fire(ScrClosure *cb, ScrDyn *const *args, size_t argc) { + (void)cb; (void)args; (void)argc; + for (int fd = 1; fd <= 2 && !scr_exc_pending(); fd++) { + ScrStdio *stream = &scr_stdio_streams[fd]; + if (stream->nresizes == 0 || !scr_process_is_tty(fd)) continue; + double columns = scr_process_columns(fd), rows = scr_process_rows(fd); + if (columns == stream->columns && rows == stream->rows) continue; + stream->columns = columns; + stream->rows = rows; + scr_stdio_resize_emit(stream); + } + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); +} + +static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool remove, bool once) { + if (remove) { + for (size_t i = stream->nresizes; i > 0; i--) { + if (scr_dyn_strict_eq(stream->resizes[i - 1], listener)) { + scr_dyn_release(stream->resizes[i - 1]); + memmove(stream->resizes + i - 1, stream->resizes + i, (stream->nresizes - i) * sizeof *stream->resizes); + memmove(stream->resize_once + i - 1, stream->resize_once + i, (stream->nresizes - i) * sizeof *stream->resize_once); + stream->nresizes--; + break; + } + } + return; + } + if (stream->nresizes == 0) { + stream->columns = scr_process_columns(stream->fd); + stream->rows = scr_process_rows(stream->fd); + } + stream->resizes = realloc(stream->resizes, (stream->nresizes + 1) * sizeof *stream->resizes); + stream->resize_once = realloc(stream->resize_once, (stream->nresizes + 1) * sizeof *stream->resize_once); + if (!stream->resizes || !stream->resize_once) scr_events_oom(); + stream->resizes[stream->nresizes] = scr_dyn_retain(listener); + stream->resize_once[stream->nresizes++] = once; +#if !defined(_WIN32) && !defined(__wasi__) + if (scr_stdio_winch == NULL && scr_context_is_main()) { + ScrClosure *cb = scr_closure_new(NULL, 0); + scr_stdio_winch = scr_dyn_new_func(cb, scr_stdio_winch_fire, 0, "", "refreshSize"); + ScrStr *name = scr_str_new("SIGWINCH", 8); + scr_signal_on(name, scr_stdio_winch, false); + scr_str_release(name); + } +#endif +} + static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -920,14 +1096,20 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; - if ((!data && !end && !error) || (stream->fd != 0 && !error)) { + bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && + memcmp(event, "resize", 6) == 0; + if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { result = scr_stdio_refusal("event"); goto done; } bool remove = method == STDIO_OFF, once = method == STDIO_ONCE; - if (stream->fd == 0) { + if (resize) { + scr_stdio_resize_listener(stream, listener, remove, once); + } else if (stream->fd == 0) { if (!remove) { - if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); + if (data) + scr_stdin_add_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, + scr_stdio_data_text, SCR_STDIN_DATA_DYN, once); if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { @@ -972,6 +1154,13 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } + } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { + ScrStr *enc = scr_strdec_encoding(arg); + if (enc) { + scr_stdin_set_encoding(enc); + scr_str_release(enc); + result = scr_dyn_retain(self); + } } else if (stream->fd == 0 && method == STDIO_DESTROY && argc == 0) { scr_stdin_destroy(); result = scr_dyn_retain(self); @@ -1069,7 +1258,12 @@ static void scr_stdio_cleanup(void) { for (size_t j = 0; j < stream->nerrors; j++) scr_dyn_release(stream->errors[j]); free(stream->errors); free(stream->error_once); stream->errors = NULL; stream->error_once = NULL; stream->nerrors = 0; + for (size_t j = 0; j < stream->nresizes; j++) scr_dyn_release(stream->resizes[j]); + free(stream->resizes); free(stream->resize_once); + stream->resizes = NULL; stream->resize_once = NULL; stream->nresizes = 0; } + scr_dyn_release(scr_stdio_winch); + scr_stdio_winch = NULL; for (int i = 0; i < STDIO_METHOD_COUNT; i++) { scr_dyn_release(scr_stdio_methods[i]); scr_stdio_methods[i] = NULL; @@ -1213,6 +1407,9 @@ void scr_stdin_data_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { /* The listener owns its +1 param per the universal convention. */ ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text) { + ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, scr_str_retain(text)); +} /* ── the loop hooks (see scr_async.c) ─────────────────────────────────── */ diff --git a/packages/runtime/src/scr_events.h b/packages/runtime/src/scr_events.h index 7dcf9cda0b..a0025783e3 100644 --- a/packages/runtime/src/scr_events.h +++ b/packages/runtime/src/scr_events.h @@ -42,6 +42,13 @@ void scr_run_exit_listeners(double code); void scr_exit_thunk0(ScrClosure *cb, double code); void scr_exit_thunk_code(ScrClosure *cb, double code); void scr_stdin_on_data(ScrClosure *cb, void (*fn)(ScrClosure *, ScrBytes *), bool once); +/* A string 'data' listener (moves cb): receives each chunk decoded by the + * stdin's setEncoding decoder; with no encoding set, a delivery throws + * the setEncoding-state TypeError instead (Node would pass a Buffer). */ +void scr_stdin_on_data_str(ScrClosure *cb, void (*fn)(ScrClosure *, ScrStr *), bool once); +/* process.stdin.setEncoding: the canonical encoding (borrowed). */ +void scr_stdin_set_encoding(ScrStr *enc); +void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text); void scr_stdin_on_end(ScrClosure *cb, bool once); void scr_stdin_on_error(ScrClosure *cb, ScrChildErrFn fn, bool once); ScrPromise *scr_stdin_next_chunk(void); /* +1 */ diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/main.ts b/tests/corpus/6236-stdin-encoding-tty-probes/main.ts new file mode 100644 index 0000000000..0fbe7eb99e --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/main.ts @@ -0,0 +1,62 @@ +// pi-tui's terminal setup on a piped stdin: the `if (process.stdin.setRawMode)` +// presence check (the method exists only on a TTY), setEncoding with a +// string 'data' listener whose chunks keep multibyte characters split +// across writes whole, and stdout 'resize' listeners (registered, +// removed, never fired off a TTY). The program re-runs itself with a +// pipe so the parent controls the write boundaries. +import { spawn } from "node:child_process"; + +if (process.argv[2] === "child") { + console.log("raw present", typeof process.stdin.setRawMode, Boolean(process.stdin.setRawMode)); + if (process.stdin.setRawMode) process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + const onResize = (): void => console.log("resize", process.stdout.columns); + process.stdout.on("resize", onResize); + process.stdout.once("resize", onResize); + process.stdout.removeListener("resize", onResize); + let total = ""; + let replacement = false; + process.stdin.on("data", (data: string) => { + if (typeof data !== "string") console.log("not a string"); + if (data.includes("�")) replacement = true; + total += data; + }); + process.stdin.on("end", () => { + console.log("end", JSON.stringify(total), "replacement", replacement); + process.stdout.removeListener("resize", onResize); + }); + process.stdin.resume(); + // Off a TTY no stream listens for SIGWINCH and its default is to ignore. + process.kill(process.pid, "SIGWINCH"); +} else { + const child = spawn(process.execPath, [process.argv[1]!, "child"], { + stdio: ["pipe", "pipe", "inherit"], + }); + let out = ""; + child.stdout!.setEncoding("utf8"); + child.stdout!.on("data", (chunk: string) => { + out += chunk; + }); + child.on("close", (code: number | null) => { + process.stdout.write(out); + console.log("child exit", code); + }); + const parts = [ + Buffer.from([0x68, 0xc3]), + Buffer.from([0xa9, 0x6c, 0x6c, 0x6f, 0x20, 0xe2, 0x82]), + Buffer.from([0xac, 0x20, 0xf0, 0x9f]), + Buffer.from([0x98, 0x80, 0x21]), + ]; + let i = 0; + const next = (): void => { + if (i < parts.length) { + const part: Buffer = parts[i]!; + i++; + child.stdin!.write(part); + setTimeout(next, 30); + } else { + child.stdin!.end(); + } + }; + next(); +} diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/package.json b/tests/corpus/6236-stdin-encoding-tty-probes/package.json new file mode 100644 index 0000000000..5ffd9800b9 --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/package.json @@ -0,0 +1 @@ +{ "type": "module" } diff --git a/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json b/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json new file mode 100644 index 0000000000..ae4eda1945 --- /dev/null +++ b/tests/corpus/6236-stdin-encoding-tty-probes/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true + } +} From c600e8daa52121751495c1707c307bb7f7396422 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 14:18:47 -0700 Subject: [PATCH 08/11] Delete V8's own stack on Error subclasses with a stack accessor quickjs-wasi's JSException constructor runs `delete this.stack` so V8's own stack property stops shadowing the class's stack accessor; scriptc fenced the delete, so every guest exception reaching the host threw the fence error. Compiled errors keep the trace internally and class dispatch already reads the accessor, so the delete now evaluates its receiver and nothing else, for Error subclasses whose chain declares a stack getter. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 22 +++++++++++++++++++ .../main.js.json | 6 +++++ .../6237-error-subclass-delete-stack/main.js | 12 ++++++++++ .../node_modules/guest-error/index.d.ts | 6 +++++ .../node_modules/guest-error/index.js | 18 +++++++++++++++ .../node_modules/guest-error/package.json | 1 + .../package.json | 1 + 7 files changed, 66 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json create mode 100644 tests/corpus/6237-error-subclass-delete-stack/main.js create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js create mode 100644 tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json create mode 100644 tests/corpus/6237-error-subclass-delete-stack/package.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 784036fddd..43babf5abf 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -5792,6 +5792,28 @@ function lowerDeleteStatement(lowerer: Lowerer, expr: ts.DeleteExpression): IrSt }; } let obj = lowerer.lowerExpr(target.expression); + // `delete this.stack` in an Error subclass that declares a `stack` + // accessor (quickjs-wasi's JSException): V8 gives each error an own + // `stack` that shadows the accessor, and the delete removes it so reads + // reach the accessor. Compiled errors keep the trace internally and + // class dispatch already reads the accessor, so only the receiver's + // evaluation remains. + if ( + obj.type.kind === "object" && + ts.isPropertyAccessExpression(target) && + target.name.text === "stack" + ) { + let accessor = false; + let errorBase = false; + for (let c = lowerer.classes.get(obj.type.className); c; c = c.base ?? undefined) { + if (c.builtinError) { + errorBase = true; + break; + } + if (c.methods.has("get:stack")) accessor = true; + } + if (accessor && errorBase) return { kind: "exprStmt", expr: obj, loc }; + } if (isJsSourceFile(expr.getSourceFile()) && obj.type.kind === "object") { const info = lowerer.classes.get(obj.type.className); const keyType = ts.isElementAccessExpression(target) diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json new file mode 100644 index 0000000000..a483858716 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6237-error-subclass-delete-stack/main.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6237-error-subclass-delete-stack/main.js" + ], + "diags": [] +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/main.js b/tests/corpus/6237-error-subclass-delete-stack/main.js new file mode 100644 index 0000000000..96ca7593de --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/main.js @@ -0,0 +1,12 @@ +// Deleting `stack` on an Error subclass that declares a stack accessor +// lets reads reach the accessor (the package's guest exceptions). +import { GuestError } from "guest-error"; + +try { + throw new GuestError(7); +} catch (e) { + console.log(e instanceof GuestError, e instanceof Error, e.handle, e.message); + console.log(e.stack); + e.stack = "rewritten"; + console.log(e.stack, Object.keys(e)); +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts new file mode 100644 index 0000000000..7f3b83f94b --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.d.ts @@ -0,0 +1,6 @@ +export declare class GuestError extends Error { + handle: number; + constructor(handle: number); + get stack(): string; + set stack(v: string); +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js new file mode 100644 index 0000000000..693c06b510 --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/index.js @@ -0,0 +1,18 @@ +// quickjs-wasi's JSException shape: an Error subclass whose stack +// accessor pair the constructor exposes by deleting V8's own `stack`. +export class GuestError extends Error { + handle; + #stack; + constructor(handle) { + super("guest error " + handle); + this.handle = handle; + delete this.stack; + this.#stack = "guest stack " + handle; + } + get stack() { + return this.#stack; + } + set stack(v) { + this.#stack = v; + } +} diff --git a/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json new file mode 100644 index 0000000000..99f59d07bf --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/node_modules/guest-error/package.json @@ -0,0 +1 @@ +{"name": "guest-error", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts"} diff --git a/tests/corpus/6237-error-subclass-delete-stack/package.json b/tests/corpus/6237-error-subclass-delete-stack/package.json new file mode 100644 index 0000000000..5ffd9800b9 --- /dev/null +++ b/tests/corpus/6237-error-subclass-delete-stack/package.json @@ -0,0 +1 @@ +{ "type": "module" } From 8ec0d9e0b9c573d85e7b8fbde3c4f5ea1258c0e1 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 15:57:42 -0700 Subject: [PATCH 09/11] Link the emitWarning replacement and extended stdio only where used The process.emitWarning replacement and the stdin encoding, string data listener and stdio 'resize' support changed runtime functions every program with the dyn-async or events unit links (the shared warning paths, stdin delivery, the process stream object) and grew their thread-local state. That code now compiles only into new runtime-pack variants, SCR_WARNING_REPLACE of scr_async_dyn.c and SCR_STDIO_EXTENDED of scr_events.c, which the link selects when the program assigns process.emitWarning or reaches setEncoding, a string 'data' listener or 'resize'. The default objects are byte-identical to before, so other executables do not change. The development and sanitizer builds pass the same defines. Co-Authored-By: Claude Opus 5.5 --- .../src/backend/executable-features.ts | 2 + .../src/backend/native-link-info-core.ts | 6 ++ .../compiler/src/backend/native/contracts.ts | 4 + .../compiler/src/backend/native/executable.ts | 4 + .../compiler/src/backend/runtime-pack-core.ts | 4 + .../src/executable/executable-cache.ts | 2 + packages/compiler/src/index.ts | 2 + packages/compiler/src/ir/runtime-features.ts | 24 ++++++ .../compiler/src/native/sanitizer.test.ts | 2 + .../runtime-pack-matrix.mjs | 17 ++++ .../runtime-darwin-arm64/scripts/build.mjs | 2 +- .../runtime-pack-matrix.mjs | 17 ++++ .../runtime-pack-common/scripts/build.mjs | 2 +- packages/runtime/src/scr_async_dyn.c | 12 ++- packages/runtime/src/scr_events.c | 86 +++++++++++++++++-- 15 files changed, 176 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/executable-features.ts b/packages/compiler/src/backend/executable-features.ts index 553643470f..7918cba20d 100644 --- a/packages/compiler/src/backend/executable-features.ts +++ b/packages/compiler/src/backend/executable-features.ts @@ -14,6 +14,8 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures { fileHandle: features.fileHandle, fetch: features.fetch, ...(features.eventTarget ? { eventTarget: true } : {}), + ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.stdioExtended ? { stdioExtended: true } : {}), zlib: features.zlib, assert: features.assert, inspect: features.inspect, diff --git a/packages/compiler/src/backend/native-link-info-core.ts b/packages/compiler/src/backend/native-link-info-core.ts index 8f69cea573..47cba0606f 100644 --- a/packages/compiler/src/backend/native-link-info-core.ts +++ b/packages/compiler/src/backend/native-link-info-core.ts @@ -22,6 +22,12 @@ export interface NativeLinkFeatures { fileHandle: boolean; fetch: boolean; eventTarget?: boolean; + /** The program assigns process.emitWarning: links the + * SCR_WARNING_REPLACE variant of the dyn-async unit. */ + warningReplace?: boolean; + /** The program uses process.stdin.setEncoding, string 'data' listeners + * or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */ + stdioExtended?: boolean; zlib: boolean; assert: boolean; inspect: boolean; diff --git a/packages/compiler/src/backend/native/contracts.ts b/packages/compiler/src/backend/native/contracts.ts index 5e981fe443..169f6231fe 100644 --- a/packages/compiler/src/backend/native/contracts.ts +++ b/packages/compiler/src/backend/native/contracts.ts @@ -112,6 +112,10 @@ export interface CcOptions { * native fetch AbortSignal built on the same engine: compiles * scr_event_target.c. */ eventTarget?: boolean; + /** SCR_WARNING_REPLACE: process.emitWarning replacement support. */ + warningReplace?: boolean; + /** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */ + stdioExtended?: boolean; /** The program uses the process-events surface (signal/exit listeners, * stdin events, for-await over stdin — moduleUsesProcessEvents on the * IR): compiles scr_events.c into the binary. Event-free binaries keep diff --git a/packages/compiler/src/backend/native/executable.ts b/packages/compiler/src/backend/native/executable.ts index 549e9ebbf5..786922ab4c 100644 --- a/packages/compiler/src/backend/native/executable.ts +++ b/packages/compiler/src/backend/native/executable.ts @@ -445,6 +445,8 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), + ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), "-fno-math-errno", // The runtime object model uses type-punned C: a hierarchy // upcast is a raw pointer cast, so one object's header (rc, vt) and @@ -643,6 +645,8 @@ export async function compileCInternal( : [optimization === "dev" ? "-O0" : "-O2"]), ...executableSectionFlags.compile, ...(opts.textDecoderLegacy ? ["-DSCR_TEXT_DECODER_LEGACY"] : []), + ...(opts.warningReplace ? ["-DSCR_WARNING_REPLACE"] : []), + ...(opts.stdioExtended ? ["-DSCR_STDIO_EXTENDED"] : []), "-fno-math-errno", "-fno-strict-aliasing", // the emitted object model type-puns — see buildArgs "-Wno-deprecated-declarations", diff --git a/packages/compiler/src/backend/runtime-pack-core.ts b/packages/compiler/src/backend/runtime-pack-core.ts index 039ecc209e..6ae14e866e 100644 --- a/packages/compiler/src/backend/runtime-pack-core.ts +++ b/packages/compiler/src/backend/runtime-pack-core.ts @@ -81,6 +81,8 @@ export interface RuntimePackManifest { export interface RuntimeFeatureSet extends NativeLinkFeatures { workers: boolean; eventTarget: boolean; + warningReplace: boolean; + stdioExtended: boolean; nativeFetch: boolean; netEffective: boolean; httpEffective: boolean; @@ -267,6 +269,8 @@ export function effectiveRuntimeFeatures( ...features, workers: features.workers === true, eventTarget: features.eventTarget === true, + warningReplace: features.warningReplace === true, + stdioExtended: features.stdioExtended === true, nativeFetch, netEffective, httpEffective, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index fbd7e4edd0..760eee25e7 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -55,6 +55,8 @@ export interface EarlyExecutableNativeFeatures { fileHandle: boolean; fetch: boolean; eventTarget?: boolean; + warningReplace?: boolean; + stdioExtended?: boolean; zlib: boolean; assert: boolean; inspect: boolean; diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 56b787e18f..82d6a54dcc 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -527,6 +527,8 @@ async function compileExecutableNative( fileHandle: features.fileHandle, fetch: features.fetch, ...(features.eventTarget ? { eventTarget: true } : {}), + ...(features.warningReplace ? { warningReplace: true } : {}), + ...(features.stdioExtended ? { stdioExtended: true } : {}), zlib: features.zlib, assert: features.assert, inspect: features.inspect, diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index ccca73c7a9..1bda9c64b8 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -16,6 +16,10 @@ export interface RuntimeFeatures { fileHandle: boolean; fetch: boolean; eventTarget: boolean; + /** process.emitWarning is assigned (process.setEmitWarning). */ + warningReplace: boolean; + /** stdin setEncoding / string data listeners / stdio 'resize' events. */ + stdioExtended: boolean; processEvents: boolean; emitter: boolean; stream: boolean; @@ -201,6 +205,10 @@ function boxesPromise(t: IrType, tables: TypeTables, seen: Set): boolean } } +/** Names that reach the extended process-stream surface (the + * SCR_STDIO_EXTENDED events unit): stdin.setEncoding and 'resize'. */ +const STDIO_EXTENDED_NAMES: ReadonlySet = new Set(["setEncoding", "resize"]); + function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): RuntimeFeatures { const features: RuntimeFeatures = { regex: false, @@ -214,6 +222,8 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run (c) => c.name === RUNTIME_EVENT_TARGET_CLASS || c.name === RUNTIME_EVENT_CLASS, ), processEvents: false, + warningReplace: false, + stdioExtended: false, emitter: mod.workers === true || (mod.classes ?? []).some((c) => c.name === RUNTIME_EMITTER_CLASS), stream: (mod.classes ?? []).some((c) => RUNTIME_STREAM_CLASSES.has(c.name)), @@ -279,6 +289,14 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run ) features.legacyTextDecoder = true; if (fn.startsWith("fetch.")) features.fetch = true; + if (fn === "process.setEmitWarning") features.warningReplace = true; + // A typed string 'data' listener takes setEncoding-decoded chunks. + if ( + fn === "stdin.onData" && + node.args[0]?.type.kind === "func" && + node.args[0].type.params[0]?.kind === "string" + ) + features.stdioExtended = true; if (fn.startsWith("event.") || fn === "events.addAbortListener") features.eventTarget = true; // Signal-taking readline, stream and timers/promises entry points // register abort listeners. @@ -495,6 +513,12 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: keyof RuntimeFeatures): Run break; case "dynInvoke": features.dynInvoke = true; + if (STDIO_EXTENDED_NAMES.has(node.method)) features.stdioExtended = true; + break; + // The process stream object's setEncoding and 'resize' members are + // reached by name (dynamic member calls and event-name strings). + case "strLit": + if (STDIO_EXTENDED_NAMES.has(node.value)) features.stdioExtended = true; break; case "awaitExpr": if (node.type.kind === "dyn") features.dynAsync = true; diff --git a/packages/compiler/src/native/sanitizer.test.ts b/packages/compiler/src/native/sanitizer.test.ts index 9047f33760..cdde641d8f 100644 --- a/packages/compiler/src/native/sanitizer.test.ts +++ b/packages/compiler/src/native/sanitizer.test.ts @@ -74,6 +74,8 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")( features: { workers: false, eventTarget: false, + warningReplace: false, + stdioExtended: false, regex: true, copying: false, textDecoderLegacy: false, diff --git a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs index 54e789624d..42745aa7eb 100644 --- a/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs +++ b/packages/runtime-darwin-arm64/runtime-pack-matrix.mjs @@ -124,6 +124,23 @@ function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + // Programs that assign process.emitWarning link the replacement-aware + // warning paths; every other program keeps the default object. + if (source === "scr_async_dyn.c") { + variants.push({ + id: "warning-replace", + when: { warningReplace: true }, + defines: ["SCR_WARNING_REPLACE"], + }); + } + // stdin setEncoding, string data listeners and stdio 'resize' events. + if (source === "scr_events.c") { + variants.push({ + id: "stdio-extended", + when: { stdioExtended: true }, + defines: ["SCR_STDIO_EXTENDED"], + }); + } return variants; } diff --git a/packages/runtime-darwin-arm64/scripts/build.mjs b/packages/runtime-darwin-arm64/scripts/build.mjs index d5c8c37206..e98e5cef7a 100644 --- a/packages/runtime-darwin-arm64/scripts/build.mjs +++ b/packages/runtime-darwin-arm64/scripts/build.mjs @@ -343,7 +343,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 6b50524822..11712eb5cc 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -114,6 +114,23 @@ function variantsFor(source) { defines: ["SCR_TEXT_DECODER_LEGACY"], }); } + // Programs that assign process.emitWarning link the replacement-aware + // warning paths; every other program keeps the default object. + if (source === "scr_async_dyn.c") { + variants.push({ + id: "warning-replace", + when: { warningReplace: true }, + defines: ["SCR_WARNING_REPLACE"], + }); + } + // stdin setEncoding, string data listeners and stdio 'resize' events. + if (source === "scr_events.c") { + variants.push({ + id: "stdio-extended", + when: { stdioExtended: true }, + defines: ["SCR_STDIO_EXTENDED"], + }); + } return variants; } diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index d1de574187..36ae930a1e 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -440,7 +440,7 @@ async function build() { ...(unitHelper === null ? {} : { speed_codegen: unitHelper.identity }), }, macros: { - executable: ["SCR_TEXT_DECODER_LEGACY"], + executable: ["SCR_TEXT_DECODER_LEGACY", "SCR_WARNING_REPLACE", "SCR_STDIO_EXTENDED"], excluded: ["SCR_RC_AUDIT", "SCR_ASAN_FIBERS"], sanitizer: "external-toolchain-required", }, diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 3e33a871bc..e9287da21d 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -860,8 +860,11 @@ ScrDyn *scr_warning_new(ScrStr *message, const char *type, const ScrStr *code) { return w; } +#ifdef SCR_WARNING_REPLACE /* The program's process.emitWarning replacement, or NULL while the - * property holds Node's own function. */ + * property holds Node's own function. Compiled only into the + * SCR_WARNING_REPLACE variant, which programs assigning process.emitWarning + * link: every other executable keeps the plain warning paths. */ static SCR_TL ScrDyn *scr_emit_warning_override = NULL; static void scr_emit_warning_override_teardown(void) { @@ -894,11 +897,13 @@ static void scr_emit_warning_replaced(ScrStr *message, ScrStr *type, ScrStr *cod scr_dyn_release(r); for (size_t i = 0; i < argc; i++) scr_dyn_release(args[i]); } +#endif /* SCR_WARNING_REPLACE */ /* A warning built from C parts (the runtime deprecation sites): name * defaults to "Warning"; code/detail optional. Reported synchronously. * Borrows message. */ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { ScrStr *type_text = scr_str_new(name ? name : "Warning", strlen(name ? name : "Warning")); ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; @@ -907,6 +912,7 @@ void scr_emit_warning(const char *name, const char *code, ScrStr *message) { scr_str_release(code_text); return; } +#endif ScrStr *code_text = code ? scr_str_new(code, strlen(code)) : NULL; ScrDyn *w = scr_warning_new(message, name ? name : "Warning", code_text); scr_str_release(code_text); @@ -937,6 +943,7 @@ static void scr_warning_defer(ScrDyn *w, void (*fire)(ScrClosure *)) { } void scr_warning_emit_deferred(ScrDyn *w) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { const ScrDyn *em = scr_dyn_obj_get(w, "message", 7); const ScrDyn *ec = scr_dyn_obj_get(w, "code", 4); @@ -947,6 +954,7 @@ void scr_warning_emit_deferred(ScrDyn *w) { scr_str_release(name); return; } +#endif if (scr_warning_is_deprecation(w)) { if (scr_process_deprecation_flag_on(0)) return; if (scr_process_deprecation_flag_on(1)) { @@ -1033,11 +1041,13 @@ static void scr_warn_bad_arg(const char *arg, const char *want) { * ERR_INVALID_ARG_TYPE TypeErrors at the call; non-string details are * ignored. The warning itself is emitted on the next tick. */ void scr_process_emit_warning(ScrDyn *args) { +#ifdef SCR_WARNING_REPLACE if (scr_emit_warning_override != NULL) { ScrDyn *r = scr_dyn_apply(scr_emit_warning_override, args, "process.emitWarning"); scr_dyn_release(r); return; } +#endif size_t argc = args->kind == SCR_DYN_ARR ? args->v.arr.len : 0; ScrDyn *const *items = args->kind == SCR_DYN_ARR ? args->v.arr.items : NULL; ScrDyn *warning = argc >= 1 ? items[0] : NULL; diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 38116e8fe0..3391f54b1d 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -345,18 +345,23 @@ static void scr_signals_drain(void) { * listener it is treated as EOF (piped fd 0 read errors are not * meaningfully reachable). */ -/* What a 'data' listener receives: raw chunks (typed Buffer listeners and - * the runtime's own consumers), the decoded string (typed string - * listeners, which need setEncoding), or whichever Node delivers - * (checked-dynamic listeners: the string once an encoding is set, else - * the Buffer). */ +#ifdef SCR_STDIO_EXTENDED +/* What a 'data' listener receives (the SCR_STDIO_EXTENDED variant, linked + * by programs that use setEncoding, string data listeners or 'resize'): + * raw chunks (typed Buffer listeners and the runtime's own consumers), the + * decoded string (typed string listeners, which need setEncoding), or + * whichever Node delivers (checked-dynamic listeners: the string once an + * encoding is set, else the Buffer). */ enum { SCR_STDIN_DATA_BYTES, SCR_STDIN_DATA_STRING, SCR_STDIN_DATA_DYN }; +#endif typedef struct { ScrClosure *cb; /* owned */ void (*fn)(ScrClosure *cb, ScrBytes *chunk); /* adapter: chunk borrowed */ +#ifdef SCR_STDIO_EXTENDED void (*str_fn)(ScrClosure *cb, ScrStr *text); /* STRING/DYN: text borrowed */ unsigned char kind; +#endif bool once; } ScrStdinDataL; @@ -385,6 +390,8 @@ static SCR_TL bool scr_stdin_resumed = false; static SCR_TL bool scr_stdin_read_eof = false; static SCR_TL ScrBytes *scr_stdin_buffer; static SCR_TL size_t scr_stdin_read_demand; + +#ifdef SCR_STDIO_EXTENDED /* process.stdin.setEncoding(enc): the canonical encoding name (NULL until * set) and the StringDecoder's packed pending partial sequence, so a * multibyte character split across reads decodes whole, as Node's @@ -434,6 +441,24 @@ void scr_stdin_set_encoding(ScrStr *enc) { scr_str_release(previous); scr_stdin_dec_pending = 0; } +#else +void scr_stdin_on_data(ScrClosure *cb /*moves*/, void (*fn)(ScrClosure *, ScrBytes *), + bool once) { + if (scr_stdin_eof || scr_stdin_destroyed) { + scr_closure_release(cb); /* never fires again, like Node post-'end' */ + return; + } + if (scr_stdin_ndata == scr_stdin_data_cap) { + scr_stdin_data_cap = scr_stdin_data_cap ? scr_stdin_data_cap * 2 : 2; + scr_stdin_data = realloc(scr_stdin_data, scr_stdin_data_cap * sizeof *scr_stdin_data); + if (!scr_stdin_data) scr_events_oom(); + } + scr_stdin_data[scr_stdin_ndata].cb = cb; + scr_stdin_data[scr_stdin_ndata].fn = fn; + scr_stdin_data[scr_stdin_ndata].once = once; + scr_stdin_ndata++; +} +#endif void scr_stdin_on_end(ScrClosure *cb /*moves*/, bool once) { if (scr_stdin_eof || scr_stdin_destroyed) { @@ -525,12 +550,14 @@ static void scr_stdin_settle_done(void) { scr_promise_release(p); } -static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); - /* EOF/teardown shared tail: 'end' fires (snapshot; once semantics are * moot — everything drops right after), the parked promise settles done, * and every listener is released. */ +#ifdef SCR_STDIO_EXTENDED +static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text); +#endif static void scr_stdin_finish(bool fire_end) { +#ifdef SCR_STDIO_EXTENDED if (fire_end && scr_stdin_encoding != NULL && scr_stdin_ndata > 0) { /* The decoder's end() flush reaches the string listeners before 'end'. */ ScrStr *tail = scr_strdec_end(scr_stdin_encoding, scr_stdin_dec_pending); @@ -539,6 +566,7 @@ static void scr_stdin_finish(bool fire_end) { scr_str_release(tail); if (scr_exc_pending()) fire_end = false; } +#endif scr_stdin_eof = true; if (fire_end) { size_t n = scr_stdin_nend; @@ -708,6 +736,7 @@ static void scr_stdin_service(void) { scr_stdin_arrived(chunk); } +#ifdef SCR_STDIO_EXTENDED static void scr_stdin_deliver(ScrBytes *chunk) { if (scr_stdin_ndata > 0 && scr_stdin_encoding != NULL) { /* Node's readable decodes once, in the stream: every listener sees @@ -723,6 +752,9 @@ static void scr_stdin_deliver(ScrBytes *chunk) { } static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { +#else +static void scr_stdin_deliver(ScrBytes *chunk) { +#endif if (scr_stdin_ndata > 0) { size_t nd = scr_stdin_ndata; ScrStdinDataL *snap = malloc(nd * sizeof *snap); @@ -743,6 +775,7 @@ static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { } } } +#ifdef SCR_STDIO_EXTENDED if (!scr_exc_pending()) { if (snap[i].kind == SCR_STDIN_DATA_BYTES) { if (chunk != NULL) snap[i].fn(snap[i].cb, chunk); @@ -760,6 +793,13 @@ static void scr_stdin_deliver_parts(ScrBytes *chunk, ScrStr *text) { } free(snap); } else if (scr_stdin_waiter && chunk != NULL) { +#else + if (!scr_exc_pending()) snap[i].fn(snap[i].cb, chunk); + scr_closure_release(snap[i].cb); + } + free(snap); + } else if (scr_stdin_waiter) { +#endif ScrPromise *p = scr_stdin_waiter; scr_stdin_waiter = NULL; scr_promise_fulfill_ref(p, scr_bytes_retain(chunk), scr_bytes_retain_v, @@ -778,14 +818,17 @@ typedef struct { bool *error_once; size_t nerrors; ScrDyn *write_error; +#ifdef SCR_STDIO_EXTENDED /* 'resize' listeners (stdout/stderr) and the size they last saw. */ ScrDyn **resizes; bool *resize_once; size_t nresizes; double columns, rows; +#endif } ScrStdio; static SCR_TL ScrStdio scr_stdio_streams[3] = {{.fd = 0}, {.fd = 1}, {.fd = 2}}; +#ifdef SCR_STDIO_EXTENDED enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_SET_ENCODING, STDIO_METHOD_COUNT }; @@ -793,6 +836,13 @@ static const char *const scr_stdio_names[] = { "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize", "setEncoding" }; +#else +enum { STDIO_WRITE, STDIO_ON, STDIO_ONCE, STDIO_OFF, STDIO_PAUSE, STDIO_RESUME, + STDIO_IS_PAUSED, STDIO_READ, STDIO_RAW, STDIO_DESTROY, STDIO_WINDOW_SIZE, STDIO_METHOD_COUNT }; +static const char *const scr_stdio_names[] = { + "write", "on", "once", "removeListener", "pause", "resume", "isPaused", "read", "setRawMode", "destroy", "getWindowSize" +}; +#endif static SCR_TL ScrDyn *scr_stdio_methods[STDIO_METHOD_COUNT]; static SCR_TL bool scr_stdio_initialized; static SCR_TL bool scr_stdio_raw; @@ -820,11 +870,13 @@ static void scr_stdio_data(ScrClosure *cb, ScrBytes *bytes) { scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +#ifdef SCR_STDIO_EXTENDED static void scr_stdio_data_text(ScrClosure *cb, ScrStr *text) { ScrDyn *chunk = scr_dyn_new_str(text); scr_stdio_fire(cb, chunk, 1); scr_dyn_release(chunk); } +#endif static void scr_stdio_end(ScrClosure *cb) { scr_stdio_fire(cb, NULL, 0); } static void scr_stdio_error(ScrClosure *cb, ScrStr *message) { ScrError *error = scr_error_new(SCR_ERR_ERROR, message); @@ -950,6 +1002,7 @@ static ScrDyn *scr_stdio_read(ScrDyn *size) { return result; } +#ifdef SCR_STDIO_EXTENDED /* 'resize' on a TTY stdout/stderr: Node's tty.WriteStream listens for * SIGWINCH and emits 'resize' when the window size changed. The signal * listener does not keep the loop alive, and a non-TTY stream never @@ -1037,6 +1090,7 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r #endif } +#endif static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc) { int method = (int)scr_box_get_f64(cb->caps[0]); ScrDyn *self = scr_dyn_this_get(); @@ -1096,13 +1150,18 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc bool data = arg->kind == SCR_DYN_STR && arg->v.str->len == 4 && memcmp(event, "data", 4) == 0; bool end = arg->kind == SCR_DYN_STR && arg->v.str->len == 3 && memcmp(event, "end", 3) == 0; bool error = arg->kind == SCR_DYN_STR && arg->v.str->len == 5 && memcmp(event, "error", 5) == 0; +#ifdef SCR_STDIO_EXTENDED bool resize = stream->fd != 0 && arg->kind == SCR_DYN_STR && arg->v.str->len == 6 && memcmp(event, "resize", 6) == 0; if ((!data && !end && !error && !resize) || (stream->fd != 0 && !error && !resize)) { +#else + if ((!data && !end && !error) || (stream->fd != 0 && !error)) { +#endif result = scr_stdio_refusal("event"); goto done; } bool remove = method == STDIO_OFF, once = method == STDIO_ONCE; +#ifdef SCR_STDIO_EXTENDED if (resize) { scr_stdio_resize_listener(stream, listener, remove, once); } else if (stream->fd == 0) { @@ -1110,6 +1169,11 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc if (data) scr_stdin_add_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, scr_stdio_data_text, SCR_STDIN_DATA_DYN, once); +#else + if (stream->fd == 0) { + if (!remove) { + if (data) scr_stdin_on_data(scr_stdio_callback(listener, (void *)scr_stdio_data), scr_stdio_data, once); +#endif if (end) scr_stdin_on_end(scr_stdio_callback(listener, (void *)scr_stdio_end), once); if (error) scr_stdin_on_error(scr_stdio_callback(listener, (void *)scr_stdio_error), scr_stdio_error, once); } else { @@ -1154,6 +1218,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc } else if (stream->fd == 0 && method == STDIO_RAW) { scr_process_stdin_set_raw_mode(scr_dyn_truthy(arg)); if (!scr_exc_pending()) { scr_stdio_raw = scr_dyn_truthy(arg); result = scr_dyn_retain(self); } +#ifdef SCR_STDIO_EXTENDED } else if (stream->fd == 0 && method == STDIO_SET_ENCODING) { ScrStr *enc = scr_strdec_encoding(arg); if (enc) { @@ -1161,6 +1226,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_str_release(enc); result = scr_dyn_retain(self); } +#endif } else if (stream->fd == 0 && method == STDIO_DESTROY && argc == 0) { scr_stdin_destroy(); result = scr_dyn_retain(self); @@ -1258,12 +1324,16 @@ static void scr_stdio_cleanup(void) { for (size_t j = 0; j < stream->nerrors; j++) scr_dyn_release(stream->errors[j]); free(stream->errors); free(stream->error_once); stream->errors = NULL; stream->error_once = NULL; stream->nerrors = 0; +#ifdef SCR_STDIO_EXTENDED for (size_t j = 0; j < stream->nresizes; j++) scr_dyn_release(stream->resizes[j]); free(stream->resizes); free(stream->resize_once); stream->resizes = NULL; stream->resize_once = NULL; stream->nresizes = 0; +#endif } +#ifdef SCR_STDIO_EXTENDED scr_dyn_release(scr_stdio_winch); scr_stdio_winch = NULL; +#endif for (int i = 0; i < STDIO_METHOD_COUNT; i++) { scr_dyn_release(scr_stdio_methods[i]); scr_stdio_methods[i] = NULL; @@ -1407,9 +1477,11 @@ void scr_stdin_data_thunk_bytes(ScrClosure *cb, ScrBytes *chunk) { /* The listener owns its +1 param per the universal convention. */ ((void (*)(ScrClosure *, ScrBytes *))cb->fn)(cb, scr_bytes_retain(chunk)); } +#ifdef SCR_STDIO_EXTENDED void scr_stdin_data_thunk_str(ScrClosure *cb, ScrStr *text) { ((void (*)(ScrClosure *, ScrStr *))cb->fn)(cb, scr_str_retain(text)); } +#endif /* ── the loop hooks (see scr_async.c) ─────────────────────────────────── */ From a1576d7c5f755be8847d3ddb29f851bfeefe16b5 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:10:05 -0700 Subject: [PATCH 10/11] Keep the deferred-class-receiver fixture on a rejected field type The fixture's class was rejected through a WeakMap-typed field, which this branch makes legal. A MapIterator-typed field is still refused, so the class stays rejected and the test still checks that a class reached only through a receiver probe reports its own diagnostic exactly once. Co-Authored-By: Claude Opus 5.5 --- tests/diagnostics/deferred-class-receiver/shelf.ts | 2 +- .../__snapshots__/deferred-class-receiver/main.ts.txt | 6 +++--- tests/harness/coverage.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/diagnostics/deferred-class-receiver/shelf.ts b/tests/diagnostics/deferred-class-receiver/shelf.ts index 1308ce5dc6..d0fba73b3b 100644 --- a/tests/diagnostics/deferred-class-receiver/shelf.ts +++ b/tests/diagnostics/deferred-class-receiver/shelf.ts @@ -1,6 +1,6 @@ export class Shelf { readonly name: string; - private readonly notes = new WeakMap(); + private readonly cursor = new Map().keys(); constructor(name: string) { this.name = name; } diff --git a/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt index 0e9cab090c..9097951746 100644 --- a/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt +++ b/tests/harness/__snapshots__/deferred-class-receiver/main.ts.txt @@ -5,9 +5,9 @@ deferred-class-receiver/main.ts:4:10 - error SC1090: constructing through a clas | ^~~~~~~~~~~~~~~ 5 | } -deferred-class-receiver/shelf.ts:3:20 - error SC1090: 'WeakMap'-typed class fields are not supported yet +deferred-class-receiver/shelf.ts:3:20 - error SC1090: 'MapIterator'-typed class fields are not supported yet 2 | readonly name: string; - 3 | private readonly notes = new WeakMap(); - | ^~~~~ + 3 | private readonly cursor = new Map().keys(); + | ^~~~~~ 4 | constructor(name: string) { \ No newline at end of file diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts index 4206514185..a120bcc3a8 100644 --- a/tests/harness/coverage.test.ts +++ b/tests/harness/coverage.test.ts @@ -391,7 +391,7 @@ test("a rejected class reached only through a receiver probe reports its own dia const { coverage } = analyze(join(repoRoot, "tests/diagnostics/deferred-class-receiver/main.ts")); const messages = coverage.diagnostics.map((d) => d.message); expect( - messages.filter((m) => m.startsWith("'WeakMap'-typed class fields")), + messages.filter((m) => m.startsWith("'MapIterator'-typed class fields")), ).toHaveLength(1); expect(messages.some((m) => m.startsWith("constructing through a class value"))).toBe(true); expect(coverage.unreached?.diagnostics ?? []).toEqual([]); From 408c63baa2cf0207bb355c03e7836a1e8469b051 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 18 Oct 2026 17:58:04 -0700 Subject: [PATCH 11/11] Declare the new runtime features in the scanner's literal order JSON.stringify of the runtime-features record follows the declared field order in the self-hosted compiler and the literal's insertion order under Node. warningReplace and stdioExtended were declared before processEvents but built after it, so the two compilers serialized the record differently (self-hosting-runtime-features). The interface now lists them in the literal's order. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/ir/runtime-features.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 1bda9c64b8..d337546bd1 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -16,11 +16,13 @@ export interface RuntimeFeatures { fileHandle: boolean; fetch: boolean; eventTarget: boolean; - /** process.emitWarning is assigned (process.setEmitWarning). */ + processEvents: boolean; + /** process.emitWarning is assigned (process.setEmitWarning). Declared + * in the literal's order: JSON.stringify of the features record must + * match between the Node-run and self-hosted compilers. */ warningReplace: boolean; /** stdin setEncoding / string data listeners / stdio 'resize' events. */ stdioExtended: boolean; - processEvents: boolean; emitter: boolean; stream: boolean; zlib: boolean;