Write run-time-typed chunks to the process streams (W2)

`process.stdout.write(data)`/`process.stderr.write(data)` with a chunk
whose type is only known at run time (`any`, or a `string | Uint8Array`
union) was refused. Lower the one-argument form to the new
`process.stdioWriteDyn` libCall, and factor the existing stdio write
method's dispatch into `scr_stdio_write_call` so both share Node's
Writable validation: a string or a Uint8Array/Buffer writes its bytes,
null throws ERR_STREAM_NULL_VALUES, and any other value throws
ERR_INVALID_ARG_TYPE, all before anything is written.

`scr_stdio_write_call` keeps its exact statements, so it is only the
call sites that change; programs that contain a JS stdio value and link
the events unit may differ (their write method's bytes).

Corpus: `tests/corpus/process-write-dynamic-chunks.ts` covers strings,
Buffer, Uint8Array, a string | Uint8Array union, a JSON field, and the
four invalid chunks with their codes and messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-29 05:37:58 -07:00
co-authored by Claude Opus 5.5
parent bd06b809f1
commit 7be1a990c7
13 changed files with 143 additions and 33 deletions
@@ -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"
]
},
+1 -1
View File
@@ -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"] },
@@ -379,6 +379,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"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
@@ -102,6 +102,7 @@ const INERT_LIB_CALLS = new Set([
"json.parse",
"process.stderrWrite",
"process.stdoutWrite",
"process.stdioWriteDyn",
"process.envGet",
"process.envPairs",
"regex.groupEquals",
@@ -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(
@@ -107,6 +107,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"ffi.argument",
"ffi.memoryModule",
"process.stdoutWrite",
"process.stdioWriteDyn",
"process.stderrWrite",
"process.stdoutWriteBytes",
"process.stderrWriteBytes",
@@ -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<any[]> 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
@@ -647,6 +647,7 @@ const PROCESS_EVENT_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"process.onExit",
"process.onSignal",
"process.stdio",
"process.stdioWriteDyn",
"stdin.nextChunk",
"stdin.onData",
"stdin.onEnd",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/process-write-dynamic-chunks.ts"
],
"diags": []
}
+4
View File
@@ -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);
+60 -32
View File
@@ -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
@@ -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);
}
}
}
@@ -0,0 +1 @@
{ "plain": 4567, "san": 86527 }