diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index 928bea3bb..14a5a4d6a 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -79679,6 +79679,7 @@ "tests/corpus/process-builtin-values.js", "tests/corpus/process-hrtime.js", "tests/corpus/process-named-signals.cjs", + "tests/corpus/process-stream-drain.ts", "tests/corpus/process-uncaught-handler-throws.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", @@ -79686,6 +79687,7 @@ "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-versions-storage.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdio-write-overrides.mjs", @@ -82049,6 +82051,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82092,6 +82096,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, @@ -82135,6 +82141,8 @@ "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", + "tests/corpus/process-stream-drain.ts", + "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts" ] }, diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 96e4418db..5b4095f9a 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -314,7 +314,7 @@ { "chapter": "process", "signatures": ["`process.hrtime([time])`", "`process.hrtime.bigint()`"], "status": "partial", "evidence": ["tests/corpus/process-hrtime.js"] }, { "chapter": "errors", "signatures": ["`stackTraceLimit` Type: {number}"], "status": "partial", "evidence": ["tests/corpus/error-stack-limit.ts"] }, { "chapter": "errors", "signatures": ["`stack` Type: {string}"], "status": "partial", "evidence": ["tests/corpus/error-stack-frames.ts", "tests/corpus/error-stack-limit.ts"] }, - { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/harness/event-loop.test.ts"] }, + { "chapter": "process", "signatures": ["`stdin` Type: {Stream}", "`stdout` Type: {Stream}", "`stderr` Type: {Stream}"], "status": "partial", "evidence": ["tests/corpus/stdio-values.mjs", "tests/corpus/stdio-write-overrides.mjs", "tests/corpus/stdio-write-errors.mjs", "tests/corpus/stdout-write-patch.ts", "tests/corpus/process-stream-drain.ts", "tests/corpus/process-write-dynamic-chunks.ts", "tests/harness/event-loop.test.ts"] }, { "chapter": "process", "signatures": ["Signal events"], "status": "partial", "evidence": ["tests/corpus/1443-signal-handlers.ts", "tests/corpus/process-named-signals.cjs"] }, { "chapter": "process", "signatures": ["Event: `'uncaughtException'`", "Event: `'uncaughtExceptionMonitor'`"], "status": "partial", "evidence": ["tests/corpus/process-uncaught-sync.cjs", "tests/corpus/process-uncaught-loop.cjs", "tests/corpus/process-uncaught-module.mjs", "tests/corpus/process-uncaught-rejection.cjs", "tests/corpus/process-uncaught-monitor.cjs", "tests/corpus/process-uncaught-handler-throws.cjs"] }, { "chapter": "process", "signatures": ["`process.getBuiltinModule(id)`"], "status": "partial", "evidence": ["tests/corpus/process-builtin-values.js", "packages/compiler/test/process-builtin-values.test.ts"] }, diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 754b5eb33..e7f918fdf 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record = { "process.envPairs": "scr_env_pairs", "process.stdoutWrite": "scr_process_stdout_write", "process.stdio": "scr_process_stdio", + "process.stdioWriteDyn": "scr_process_stdio_write_dyn", "process.stderrWrite": "scr_process_stderr_write", "process.isTTY": "scr_process_is_tty", // A promise that never settles (events.once of an event a synchronous diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 56379a443..7ef02a978 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -102,6 +102,7 @@ const INERT_LIB_CALLS = new Set([ "json.parse", "process.stderrWrite", "process.stdoutWrite", + "process.stdioWriteDyn", "process.envGet", "process.envPairs", "regex.groupEquals", diff --git a/packages/compiler/src/frontend/lowering/builtins/process.ts b/packages/compiler/src/frontend/lowering/builtins/process.ts index 87fd66412..e5c9bd5af 100644 --- a/packages/compiler/src/frontend/lowering/builtins/process.ts +++ b/packages/compiler/src/frontend/lowering/builtins/process.ts @@ -1357,6 +1357,24 @@ export function lowerProcessMethodCall( if (data.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(call))) { data = { kind: "dynCheck", value: data, type: STRING, loc }; } + // A chunk known only at run time (`any`, or a string | Uint8Array + // union): the runtime dispatches on the value — a string or a + // Uint8Array/Buffer writes, null throws ERR_STREAM_NULL_VALUES, any + // other value throws ERR_INVALID_ARG_TYPE, as Node's Writable.write + // does before writing anything. Only the one-argument form. + if ( + args.length === 1 && + (data.type.kind === "dyn" || data.type.kind === "union") && + (data.type.kind === "dyn" || lowerer.dynConvertible(data.type)) + ) { + return { + kind: "libCall", + fn: "process.stdioWriteDyn", + args: [numLit(stream === "stdout" ? 1 : 2, loc), lowerer.coerceToExpected(data, DYN)], + type: BOOL, + loc, + }; + } const isBytes = data.type.kind === "bytes" && data.type.elem === "u8"; if (!isBytes && data.type.kind !== "string") { lowerer.noLowering( diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 114231730..85005cfc2 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -107,6 +107,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "ffi.argument", "ffi.memoryModule", "process.stdoutWrite", + "process.stdioWriteDyn", "process.stderrWrite", "process.stdoutWriteBytes", "process.stderrWriteBytes", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 55596d087..d8d3ce999 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3668,6 +3668,9 @@ function buildLibFnSignatures() { * never throws. */ "process.stdoutWrite": { argTypes: [STRING], result: BOOL }, "process.stdio": { argTypes: [F64], result: DYN }, + /** process.stdout/stderr.write(chunk) with a run-time chunk: Node's + * Writable validation, then a synchronous write. */ + "process.stdioWriteDyn": { argTypes: [F64, DYN], result: BOOL }, "process.stderrWrite": { argTypes: [STRING], result: BOOL }, // error.new's result and the receiver slots are builtin-error classes — // program-dependent object types, checked in the libCall case. @@ -5341,6 +5344,10 @@ function buildLibFnSignatures() { /** Validated isatty(3) probe. Stream property lowering maps false to * undefined; tty.isatty exposes the boolean result directly. */ "process.isTTY": { argTypes: [F64], result: BOOL }, + /** A promise that never settles (events.once of an event that cannot + * occur, e.g. 'drain' on a synchronous process stream). The result is + * events.once's own Promise type. */ + "events.pendingForever": { argTypes: [], result: { kind: "promise", inner: arrayOf(DYN) } }, // Like process.envGet: the result is the module's interned // `number | undefined` union — checked by arms in the libCall case. /** Terminal width over an fd literal (1/2 — process.stdout/stderr diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 93b78db14..f922204d0 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet = new Set([ "process.onExit", "process.onSignal", "process.stdio", + "process.stdioWriteDyn", "stdin.nextChunk", "stdin.onData", "stdin.onEnd", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..0558bbdd6 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/process-write-dynamic-chunks.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/process-write-dynamic-chunks.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_blob.h b/packages/runtime/src/scr_blob.h index 7c7893322..53d2d2228 100644 --- a/packages/runtime/src/scr_blob.h +++ b/packages/runtime/src/scr_blob.h @@ -68,6 +68,10 @@ ScrDyn *scr_console_new(double out_fd, double err_fd, bool ignore_errors); void scr_console_bind(ScrDyn *console, double which, ScrStream *stream); void scr_console_inst_write(ScrDyn *console, ScrStr *text, bool to_stderr); ScrDyn *scr_process_stdio(double fd); +/* process.stdout/stderr.write(chunk) with a run-time chunk: Node's Writable + * validation, then a synchronous write. false with an exception pending when + * the chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk); bool scr_dyn_process_stdio_is(const ScrDyn *value); double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path); diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index 9de2c6ce2..8794c270c 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1311,6 +1311,52 @@ static void scr_stdio_resize_listener(ScrStdio *stream, ScrDyn *listener, bool r } #endif + +/* stream.write(chunk[, encoding][, callback]) on a process output stream: + * Node's Writable.write validation, then a synchronous descriptor write. + * Returns +1 (true/false) or NULL with an exception pending. */ +static ScrDyn *scr_stdio_write_call(ScrStdio *stream, ScrDyn *const *args, size_t argc) { + ScrDyn *arg = argc ? args[0] : scr_dyn_undefined(); + ScrDyn *result = NULL; + ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); + ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); + if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } + if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { + scr_dyn_arg_type_fail("cb", "of type function", callback); + return NULL; + } + if (arg->kind == SCR_DYN_NULL) { + const char *m = "May not write null values to stream"; + scr_throw_error_msg_code(SCR_ERR_TYPE, m, strlen(m), "ERR_STREAM_NULL_VALUES"); + return NULL; + } + if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem != SCR_BYTES_U8) + return scr_stdio_refusal("write of a typed array other than Uint8Array"); + ScrBytes *bytes = NULL; + if (arg->kind == SCR_DYN_STR) { + if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { + return scr_stdio_refusal("write encoding"); + } + ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); + bytes = scr_bytes_from_str(arg->v.str, enc); + scr_str_release(enc); + } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { + bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); + } else { + scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); + return NULL; + } + if (!bytes) return NULL; + int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); + scr_bytes_release(bytes); + if (error) { + scr_stdio_queue_write_error(stream, callback, error); + return scr_dyn_new_bool(false); + } + if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); + return scr_dyn_new_bool(true); +} + 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(); @@ -1328,38 +1374,7 @@ static ScrDyn *scr_stdio_method(ScrClosure *cb, ScrDyn *const *args, size_t argc scr_dyn_arr_push(result, columns); scr_dyn_arr_push(result, rows); } else if (method == STDIO_WRITE && stream->fd != 0) { - ScrDyn *encoding = argc > 1 ? args[1] : scr_dyn_undefined(); - ScrDyn *callback = argc > 2 ? args[2] : scr_dyn_undefined(); - if (encoding->kind == SCR_DYN_FUNC) { callback = encoding; encoding = scr_dyn_undefined(); } - if (callback->kind != SCR_DYN_UNDEF && callback->kind != SCR_DYN_NULL && callback->kind != SCR_DYN_FUNC) { - scr_dyn_arg_type_fail("cb", "of type function", callback); - goto done; - } - ScrBytes *bytes = NULL; - if (arg->kind == SCR_DYN_STR) { - if (encoding->kind != SCR_DYN_UNDEF && encoding->kind != SCR_DYN_NULL && encoding->kind != SCR_DYN_STR) { - result = scr_stdio_refusal("write encoding"); - goto done; - } - ScrStr *enc = encoding->kind == SCR_DYN_STR ? scr_str_retain(encoding->v.str) : scr_str_new("utf8", 4); - bytes = scr_bytes_from_str(arg->v.str, enc); - scr_str_release(enc); - } else if (arg->kind == SCR_DYN_BYTES && arg->v.bytes->elem == SCR_BYTES_U8) { - bytes = arg->v.bytes->shared ? scr_bytes_copy(arg->v.bytes) : scr_bytes_retain(arg->v.bytes); - } else { - scr_dyn_arg_type_fail("chunk", "of type string or an instance of Buffer, TypedArray, or DataView", arg); - goto done; - } - if (!bytes) goto done; - int error = scr_stdio_write_raw(stream->fd, bytes->data, bytes->len); - scr_bytes_release(bytes); - if (error) { - scr_stdio_queue_write_error(stream, callback, error); - result = scr_dyn_new_bool(false); - goto done; - } - if (callback->kind == SCR_DYN_FUNC) scr_next_tick(scr_stdio_callback(callback, (void *)scr_stdio_write_done)); - result = scr_dyn_new_bool(true); + result = scr_stdio_write_call(stream, args, argc); } else if (method == STDIO_ON || method == STDIO_ONCE || method == STDIO_OFF) { ScrDyn *listener = argc > 1 ? args[1] : scr_dyn_undefined(); if (listener->kind != SCR_DYN_FUNC) { @@ -1597,6 +1612,19 @@ double scr_dyn_process_stdio_fd(const ScrDyn *value, const ScrDynPath *path) { return stream ? stream->fd : 0; } +/* process.stdout/stderr.write(chunk) with a run-time chunk: the stream + * method's own validation and write, without creating the stream value + * or installing the write hook. False with an exception pending when the + * chunk is invalid. */ +bool scr_process_stdio_write_dyn(double fd, ScrDyn *chunk) { + ScrStdio *stream = &scr_stdio_streams[fd == 2 ? 2 : 1]; + ScrDyn *result = scr_stdio_write_call(stream, &chunk, 1); + if (!result) return false; + bool accepted = scr_dyn_truthy(result); + scr_dyn_release(result); + return accepted; +} + /* ── the process 'exit' event ───────────────────────────────────────── * Listeners run SYNCHRONOUSLY when the process exits: at the end of a * normal run (the unit's atexit, registered by scr_events_install after diff --git a/tests/corpus/process-write-dynamic-chunks.ts b/tests/corpus/process-write-dynamic-chunks.ts new file mode 100644 index 000000000..4356943b2 --- /dev/null +++ b/tests/corpus/process-write-dynamic-chunks.ts @@ -0,0 +1,34 @@ +// process.stdout/stderr.write of chunks whose type is only known at run +// time: `any` values (a child's 'data' chunk, a JSON field) and +// string | Uint8Array unions. Strings and Uint8Array/Buffer values write +// their bytes; null throws ERR_STREAM_NULL_VALUES and any other value +// throws ERR_INVALID_ARG_TYPE, both TypeErrors raised synchronously before +// anything is written. + +function forward(data: any): boolean { + return process.stdout.write(data); +} + +function pick(useBytes: boolean): string | Uint8Array { + return useBytes ? new Uint8Array([98, 121, 116, 101, 115, 10]) : "text\n"; +} + +console.log("string", forward("any string\n")); +console.log("buffer", forward(Buffer.from("any buffer\n"))); +console.log("u8", forward(new TextEncoder().encode("any u8\n"))); +process.stdout.write(pick(false)); +process.stdout.write(pick(true)); +process.stderr.write(pick(false)); +const parsed: any = JSON.parse('{"chunk":"from json\\n","n":42}'); +forward(parsed.chunk); + +for (const bad of [parsed.n, null, undefined, { a: 1 }]) { + try { + forward(bad); + console.log("no throw"); + } catch (error) { + if (error instanceof TypeError) { + console.log((error as NodeJS.ErrnoException).code, error.message); + } + } +} diff --git a/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json new file mode 100644 index 000000000..b83881cc0 --- /dev/null +++ b/tests/harness/corpus-costs/process-write-dynamic-chunks.ts.json @@ -0,0 +1 @@ +{ "plain": 4567, "san": 86527 }