feat: support process write callback and encoding args (#114)

* feat: support process write callback and encoding args

- Lower static stdout and stderr writes with encoding and callback overloads.
- Schedule completion callbacks through the next-tick queue in C and LLVM builds.
- Cover encoding aliases, ordering, and async-free refusal behavior.

* fix: handle omitted process write arguments
This commit is contained in:
Chris Tate
2026-08-10 10:07:08 -05:00
committed by GitHub
parent b94c88e31c
commit 17ee5eff94
11 changed files with 321 additions and 41 deletions
+12 -6
View File
@@ -205,12 +205,18 @@ declare var process: {
/* The raw byte writes — no newline, no formatting. stdout shares
* console.log's stream, each call is submitted promptly, and ordering is
* preserved. The boolean is Node's backpressure signal — these synchronous
* writes always return true. The Uint8Array overload
* and isTTY exist so real CLIs TYPECHECK (they are @types/node surface);
* only the one-string form has a lowering — everything else fences at
* its use site. */
stdout: { write(data: string): boolean; write(data: Uint8Array): boolean; readonly isTTY: boolean };
stderr: { write(data: string): boolean; write(data: Uint8Array): boolean; readonly isTTY: boolean };
* writes always return true. Static BufferEncoding arguments and the
* completion callback overloads match Node's WritableStream surface. */
stdout: {
write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean;
write(data: string | Uint8Array, encoding: BufferEncoding, callback?: (error?: Error | null) => void): boolean;
readonly isTTY: boolean;
};
stderr: {
write(data: string | Uint8Array, callback?: (error?: Error | null) => void): boolean;
write(data: string | Uint8Array, encoding: BufferEncoding, callback?: (error?: Error | null) => void): boolean;
readonly isTTY: boolean;
};
/* The stdin stream, the piped-input slice: the TTY probe, the
* data/end/error events (on and once — a 'data' listener keeps the
* event loop alive until EOF, like Node's flowing stdin), destroy()
@@ -5417,9 +5417,27 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
case "zlib.inflateSync":
return finish(`scr_zlib_inflate(${arg(0)})`);
case "process.stdoutWriteBytes":
return finish(`scr_process_stdout_write_bytes(${arg(0)})`);
return finish(`scr_process_stdout_write_bytes(${arg(0)}, ${arg(1)})`);
case "process.stderrWriteBytes":
return finish(`scr_process_stderr_write_bytes(${arg(0)})`);
return finish(`scr_process_stderr_write_bytes(${arg(0)}, ${arg(1)})`);
case "process.stdoutWriteBytesCb":
case "process.stderrWriteBytesCb": {
// Submit the bytes only after every call argument evaluated,
// then move the completion callback onto the next-tick queue.
// The shared error-first adapter materializes success `null`.
E.usesTimers = true;
const cbT = e.args[2]!.type;
if (cbT.kind !== "func") throw new Error("emitter bug: process write callback not a func");
const cb = args[2]!;
E.moveTemp(cb);
const adapter = E.fsRenameThunkFor(cbT);
const write = e.fn === "process.stdoutWriteBytesCb"
? "scr_process_stdout_write_bytes"
: "scr_process_stderr_write_bytes";
const out = E.newTemp(e.type, `${write}(${arg(0)}, ${arg(1)})`);
E.line(`scr_process_write_callback(${cb.name}, &${adapter});${E.srcComment(e.loc)}`);
return out;
}
case "tp.setTimeout":
return finish(`scr_tp_set_timeout(${arg(0)})`);
case "tp.setImmediate":
@@ -11049,6 +11049,26 @@ class LlEmitter {
const B = this.B;
// Loop liveness first (one table for generic and special shapes).
if (USES_TIMERS_LIB_FNS.has(e.fn)) this.usesTimers = true;
if (e.fn === "process.stdoutWriteBytesCb" || e.fn === "process.stderrWriteBytesCb") {
// All arguments evaluate before bytes are submitted. The callback
// then moves into the next-tick entry and its error-first adapter
// constructs Node's success `null` argument when the tick fires.
this.usesTimers = true;
const args = e.args.map((a) => this.emitExpr(a));
const cbT = e.args[2]!.type;
if (cbT.kind !== "func") throw new Error("llvm emitter bug: process write callback not a func");
this.moveTemp(args[2]!);
const adapter = this.fsRenameThunkFor(cbT);
const write = e.fn === "process.stdoutWriteBytesCb"
? "scr_process_stdout_write_bytes"
: "scr_process_stderr_write_bytes";
this.declare(`declare zeroext i1 @${write}(ptr, ptr)`);
this.declare(`declare void @scr_process_write_callback(ptr, ptr)`);
const out = B.tmp();
B.line(`${out} = call i1 @${write}(ptr ${args[0]!.name}, ptr ${args[1]!.name})`);
B.line(`call void @scr_process_write_callback(ptr ${args[2]!.name}, ptr @${adapter})`);
return { name: out, type: e.type };
}
if (e.fn === "fs.renameCb") {
// The callback MOVES into the next-turn operation. Its emitted
// adapter materializes the callback's Error | null union (or the
@@ -5910,12 +5910,13 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
export function lowerProcessMethodCall(L: Lowerer, call: ts.CallExpression,
access: ts.PropertyAccessExpression,): IrExpr | null {
if (call.questionDotToken) return null;
// process.stdout.write(s) / process.stderr.write(s): the raw byte
// write — no newline, no formatting. stdout shares console.log's
// process.stdout.write(s[, encoding][, callback]) and stderr's twin:
// raw bytes, no newline or formatting. stdout shares console.log's
// promptly-submitted stream, preserving source order. Node's boolean
// is a backpressure signal; this synchronous write is constantly true.
// @types/node's wider forms (Buffer data, encoding, callback) typecheck
// and fence here.
// String encodings are compile-time-known BufferEncoding spellings;
// byte chunks evaluate but ignore the encoding like Node. Completion
// callbacks ride the next-tick queue and receive the success `null`.
// process.stdin.destroy(): a deliberate no-op — no stream machinery
// exists to tear down, and no other stdin surface observes the
// destroyed state (SEMANTICS.md documents it).
@@ -6037,14 +6038,74 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
const stream = L.stdlibGlobalMember(access.expression, "process");
if (stream === "stdout" || stream === "stderr") {
const loc = locOf(call);
if (call.arguments.length !== 1) {
const args = call.arguments;
if (args.length < 1 || args.length > 3 || args.some(ts.isSpreadElement)) {
L.noLowering(
`process.${stream}.write with ${call.arguments.length} arguments`,
`process.${stream}.write with ${args.length} arguments`,
call,
"the supported form is write(data) with one string — encodings and callbacks have no lowering",
"the supported forms are write(data[, encoding][, callback]) with a static BufferEncoding and completion callback",
);
}
let data = L.lowerExpr(call.arguments[0]!);
const secondNode = args[1];
const thirdNode = args[2];
const secondUndefined = secondNode
? lowerStaticallyUndefinedBuiltinArg(L, secondNode)
: null;
const thirdUndefined = thirdNode
? lowerStaticallyUndefinedBuiltinArg(L, thirdNode)
: null;
const secondT = secondNode && !secondUndefined ? L.mapTypeOf(L.typeOf(secondNode)) : undefined;
const callbackNode = args.length === 3 && !thirdUndefined
? thirdNode!
: args.length === 2 && secondT?.kind === "func"
? secondNode!
: undefined;
const encodingNode =
(args.length === 3 || (args.length === 2 && callbackNode === undefined)) && !secondUndefined
? secondNode!
: undefined;
// Node's BufferEncoding aliases normalize before bytes are made.
// Preserve an effectful literal-typed expression even when its
// spelling folds (for example a function returning `"binary"`).
const encoding: IrExpr = ((): IrExpr => {
const defaultEncoding = { kind: "strLit", value: "utf8", type: STRING, loc } satisfies IrExpr;
if (secondUndefined) {
return defaultAfterUndefined(secondUndefined, defaultEncoding);
}
if (!encodingNode) {
return defaultEncoding;
}
const aliases: Record<string, string | undefined> = {
utf8: "utf8", "utf-8": "utf8", hex: "hex", base64: "base64",
base64url: "base64url", latin1: "latin1", binary: "latin1", ascii: "ascii",
utf16le: "utf16le", "utf-16le": "utf16le", ucs2: "utf16le", "ucs-2": "utf16le",
};
const t = L.typeOf(encodingNode);
const raw = t.isStringLiteralType() ? t.value : undefined;
const canonical = raw !== undefined ? own(aliases, raw) : undefined;
if (canonical === undefined) {
L.noLowering(
`process.${stream}.write with this encoding`,
encodingNode,
'use a literal "utf8", "hex", "base64", "base64url", "latin1", "binary", "ascii", "utf16le", or "ucs2" encoding',
);
}
const evaluated = L.lowerExprExpecting(encodingNode, STRING);
if (canonical === raw) return evaluated;
const normalized: IrExpr = { kind: "strLit", value: canonical, type: STRING, loc: locOf(encodingNode) };
return droppableStatic(evaluated)
? normalized
: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: evaluated, loc: evaluated.loc }],
result: normalized,
type: STRING,
loc: evaluated.loc,
};
})();
let data = L.lowerExpr(args[0]!);
// A checked-dynamic argument in a JS file takes the validated
// string exit (the trust-but-verify boundary: commander's
// `writeOut: (str) => process.stdout.write(str)` — str untyped):
@@ -6053,24 +6114,92 @@ function optionMember(p: ts.ObjectLiteralElementLike): { name: string; value: ts
if (data.type.kind === "dyn" && isJsSourceFile(call.getSourceFile())) {
data = { kind: "dynCheck", value: data, type: STRING, loc };
}
// The Buffer overload writes raw bytes through the same promptly
// submitted streams.
if (data.type.kind === "bytes" && data.type.elem === "u8") {
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
if (!isBytes && data.type.kind !== "string") {
L.noLowering(
`process.${stream}.write of non-string data`,
args[0]!,
"strings and Buffer/Uint8Array values write; narrow unions first",
);
}
let callback: IrExpr | undefined;
if (callbackNode) {
callback = L.lowerExpr(callbackNode);
if (callback.type.kind === "dyn" && isJsSourceFile(call.getSourceFile())) {
callback = {
kind: "dynCheck",
value: callback,
type: funcOf([DYN], VOID),
loc: locOf(callbackNode),
};
}
let callbackOk = callback.type.kind === "func" && callback.type.params.length <= 1;
if (callbackOk && callback.type.kind === "func" && callback.type.params.length === 1) {
const param = callback.type.params[0]!;
if (param.kind !== "dyn") {
const def = param.kind === "union" ? L.unions.get(param.unionId) : undefined;
callbackOk = !!def &&
def.arms.some((a) => a.kind === "nullT") &&
def.arms.some((a) => a.kind === "object" && a.className === "%Error") &&
def.arms.every((a) =>
a.kind === "nullT" || a.kind === "undefinedT" ||
(a.kind === "object" && a.className === "%Error"));
}
}
if (!callbackOk) {
L.unsupported(
"SC1090",
callbackNode,
"process output completion callbacks must accept at most one Error | null parameter",
);
}
callback = voidizedCallback(L, callback, locOf(callbackNode));
}
// Encoding- or callback-bearing writes use one fixed byte ABI. For
// strings, Buffer.from's encoder runs after data/encoding evaluation
// and before the callback expression; only its pure allocation moves
// earlier than Node's internal write conversion.
if (callback || args.length > 1 || isBytes) {
const bytes = isBytes
? data
: { kind: "libCall", fn: "buffer.fromStr", args: [data, encoding], type: BYTES_U8, loc } satisfies IrExpr;
const defaultRuntimeEncoding = { kind: "strLit", value: "utf8", type: STRING, loc } satisfies IrExpr;
let runtimeEncoding = isBytes ? encoding : defaultRuntimeEncoding;
// An explicitly-undefined third argument is still evaluated after
// data and encoding, even though it schedules no callback. Strings
// already evaluate encoding while producing `bytes`; byte chunks
// fold both ignored argument effects into this final ABI slot.
if (thirdUndefined && !droppableStatic(thirdUndefined)) {
const effects = isBytes
? [encoding, thirdUndefined].filter((effect) => !droppableStatic(effect))
: [thirdUndefined];
runtimeEncoding = {
kind: "seqExpr",
stmts: effects.map((effect) => ({ kind: "exprStmt", expr: effect, loc: effect.loc })),
result: defaultRuntimeEncoding,
type: STRING,
loc: thirdUndefined.loc,
};
}
if (callback) {
return {
kind: "libCall",
fn: stream === "stdout" ? "process.stdoutWriteBytesCb" : "process.stderrWriteBytesCb",
args: [bytes, runtimeEncoding, callback],
type: BOOL,
loc,
};
}
return {
kind: "libCall",
fn: stream === "stdout" ? "process.stdoutWriteBytes" : "process.stderrWriteBytes",
args: [data],
args: [bytes, runtimeEncoding],
type: BOOL,
loc,
};
}
if (data.type.kind !== "string") {
L.noLowering(
`process.${stream}.write of non-string data`,
call.arguments[0]!,
"strings and Buffer/Uint8Array values write; narrow unions first",
);
}
return {
kind: "libCall",
fn: stream === "stdout" ? "process.stdoutWrite" : "process.stderrWrite",
+7 -1
View File
@@ -2882,9 +2882,13 @@ export type IrLibFn =
| "zlib.deflateSync"
| "zlib.inflateSync"
/** The Buffer overloads of the raw stream writes — same promptly
* submitted streams as process.stdoutWrite/stderrWrite, constantly true. */
* submitted streams as process.stdoutWrite/stderrWrite, constantly true.
* The encoding arg is evaluated but ignored for bytes, like Node. The Cb
* forms move their program-shaped completion callback to the tick queue. */
| "process.stdoutWriteBytes"
| "process.stderrWriteBytes"
| "process.stdoutWriteBytesCb"
| "process.stderrWriteBytesCb"
| "fsp.readFile"
| "fsp.writeFile"
| "fsp.mkdir"
@@ -6490,6 +6494,8 @@ const LIB_MODE_REFUSED_PREFIXES: readonly [string, string][] = [
// exclude — refuse the surface like the rest of the event-loop family.
["fs.existsChk", "the async fs callback surface (fs.exists)"],
["fs.renameCb", "the async fs callback surface (fs.rename)"],
["process.stdoutWriteBytesCb", "process.stdout.write completion callbacks"],
["process.stderrWriteBytesCb", "process.stderr.write completion callbacks"],
["timers.", "the timers surface (setTimeout family)"],
["tp.", "the timers/promises surface"],
["cp.", "the child_process surface"],
+12 -4
View File
@@ -751,8 +751,12 @@ export const LIB_FN_SIGS: Record<IrLibFn, { argTypes: (IrType | null)[]; result:
"fsp.readFileBytes": { argTypes: [STRING], result: { kind: "promise", inner: BYTES_U8 } },
"zlib.deflateSync": { argTypes: [BYTES_U8], result: BYTES_U8 },
"zlib.inflateSync": { argTypes: [BYTES_U8], result: BYTES_U8 },
"process.stdoutWriteBytes": { argTypes: [BYTES_U8], result: BOOL },
"process.stderrWriteBytes": { argTypes: [BYTES_U8], result: BOOL },
"process.stdoutWriteBytes": { argTypes: [BYTES_U8, STRING], result: BOOL },
"process.stderrWriteBytes": { argTypes: [BYTES_U8, STRING], result: BOOL },
// Completion callback is program-dependent: zero params, checked-dynamic,
// or an optional Error | null slot (same success shape as fs.rename).
"process.stdoutWriteBytesCb": { argTypes: [BYTES_U8, STRING, null], result: BOOL },
"process.stderrWriteBytesCb": { argTypes: [BYTES_U8, STRING, null], result: BOOL },
"fsp.readFile": { argTypes: [STRING, STRING], result: { kind: "promise", inner: STRING } },
"fsp.writeFile": { argTypes: [STRING, STRING], result: { kind: "promise", inner: VOID } },
"fsp.mkdir": { argTypes: [STRING], result: { kind: "promise", inner: VOID } },
@@ -3972,7 +3976,11 @@ function validateFunction(
}
break;
}
if (e.fn === "fs.renameCb") {
if (
e.fn === "fs.renameCb" ||
e.fn === "process.stdoutWriteBytesCb" ||
e.fn === "process.stderrWriteBytesCb"
) {
// The callback is void and accepts either no parameters, one
// checked-dynamic error slot (JS), or Error | null (optionally
// including undefined for an explicitly optional parameter).
@@ -3994,7 +4002,7 @@ function validateFunction(
}
}
if (!ok) {
err(`libCall fs.renameCb callback shape (frontend must fence)`, e.loc);
err(`libCall ${e.fn} callback shape (frontend must fence)`, e.loc);
}
break;
}
+20 -3
View File
@@ -628,8 +628,9 @@ ScrArr *scr_active_resources(void) {
* left queued on the uncaught paths) never run — the teardown releases
* them, like Node dropping the queue at exit. */
typedef struct ScrNtick {
ScrClosure *cb; /* owned; NULL for a raw C-hook entry */
void (*raw)(void); /* the hook when cb == NULL (stream tick markers) */
ScrClosure *cb; /* owned; NULL for a raw C-hook entry */
ScrFsRenameFn error_cb; /* process-write success adapter, or NULL */
void (*raw)(void); /* hook when cb == NULL (stream markers) */
struct ScrNtick *next;
} ScrNtick;
@@ -650,6 +651,20 @@ void scr_next_tick(ScrClosure *cb /*moves*/) {
scr_nt_tail = t;
}
/* stdout/stderr write completions are Node nextTicks too, but their
* callback receives the success `null` argument. Reuse the error-first
* adapter ABI also used by fs.rename: emitted adapters construct the
* program's Error | null union, and NULL selects its null arm. */
void scr_process_write_callback(ScrClosure *cb /*moves*/, ScrFsRenameFn fn) {
ScrNtick *t = calloc(1, sizeof *t);
if (!t) scr_oom();
t->cb = cb;
t->error_cb = fn;
if (scr_nt_tail) scr_nt_tail->next = t;
else scr_nt_head = t;
scr_nt_tail = t;
}
/* A RAW C-hook tick: the stream unit enqueues one marker per deferred
* stream emission so those emissions interleave with user nextTicks in
* true FIFO order — in Node they ARE nextTicks (resume_, emitReadable_,
@@ -2006,10 +2021,12 @@ bool scr_loop_run(ScrPromise *top_level) {
scr_nt_head = t->next;
if (scr_nt_head == NULL) scr_nt_tail = NULL;
ScrClosure *cb = t->cb;
ScrFsRenameFn error_cb = t->error_cb;
void (*raw)(void) = t->raw;
free(t);
if (cb) {
((void (*)(ScrClosure *))cb->fn)(cb);
if (error_cb) error_cb(cb, NULL);
else ((void (*)(ScrClosure *))cb->fn)(cb);
scr_closure_release(cb);
} else {
raw(); /* one stream tick, FIFO with the user ticks around it */
+4 -2
View File
@@ -153,12 +153,14 @@ ScrBytes *scr_crypto_random_bytes(double n) {
/* ── process.stdout/stderr.write(buf) ──────────────────────────────────── */
bool scr_process_stdout_write_bytes(const ScrBytes *b) {
bool scr_process_stdout_write_bytes(const ScrBytes *b, const ScrStr *encoding) {
(void)encoding;
scr_stdio_write(1, b->data, b->len * scr_bytes_elem_size(b->elem));
return true;
}
bool scr_process_stderr_write_bytes(const ScrBytes *b) {
bool scr_process_stderr_write_bytes(const ScrBytes *b, const ScrStr *encoding) {
(void)encoding;
scr_stdio_write(2, b->data, b->len * scr_bytes_elem_size(b->elem));
return true;
}
+10 -4
View File
@@ -3783,6 +3783,10 @@ bool scr_immediate_has_ref(double handle);
* teardown (they must never run yet must not leak). cb ownership moves
* in. */
void scr_next_tick(ScrClosure *cb);
/* A successful process.stdout/stderr.write completion on that same queue.
* The callback and its program-shaped Error | null adapter both MOVE into
* the entry; the adapter is invoked with NULL (Node's success argument). */
void scr_process_write_callback(ScrClosure *cb, ScrFsRenameFn fn);
/* A raw C-hook entry on the SAME queue: the stream unit enqueues one
* marker per deferred stream emission, so stream ticks and user
* nextTicks run in true FIFO order (in Node they are the same queue).
@@ -4978,10 +4982,12 @@ ScrPromise *scr_fsp_read_file_bytes(ScrStr *path); /* +1 */
* RangeError catchably (same check as scr_crypto_random_string). */
ScrBytes *scr_crypto_random_bytes(double n); /* +1 */
/* process.stdout/stderr.write(buf): the raw byte writes' Buffer overloads
* (same streams and buffering as the string forms). Constantly true. */
bool scr_process_stdout_write_bytes(const ScrBytes *b);
bool scr_process_stderr_write_bytes(const ScrBytes *b);
/* process.stdout/stderr.write(buf[, encoding]): the raw byte writes' Buffer
* overloads (same streams and buffering as the string forms). The encoding
* is evaluated by the caller and ignored here, as Node does for bytes.
* Constantly true. */
bool scr_process_stdout_write_bytes(const ScrBytes *b, const ScrStr *encoding);
bool scr_process_stderr_write_bytes(const ScrBytes *b, const ScrStr *encoding);
/* The Node-shaped fs error thrower (scr_lib.c): formats "ENOENT: no such
* file or directory, open 'x'" and throws it catchably. Shared with
+50
View File
@@ -0,0 +1,50 @@
// Static process stdout/stderr writes: BufferEncoding arguments affect
// strings, are ignored for byte chunks, and completion callbacks run later
// with Node's success `null`. Argument effects still precede submission.
function chunk(): string {
console.log("data-expression");
return "7374617469632d656e636f6465647c";
}
function encoding(): "hex" {
console.log("encoding-expression");
return "hex";
}
function completion(label: string): (error?: Error | null) => number {
console.log(`callback-expression:${label}`);
return (error) => {
console.log(`callback:${label}:${error === null}`);
return 1; // Writable.write ignores completion callback results.
};
}
function omitted(label: string): undefined {
console.log(`omitted-expression:${label}`);
return undefined;
}
process.stdout.write("literal-two-undefined|", undefined);
process.stdout.write("literal-three-undefined|", "utf8", undefined);
process.stdout.write("effect-two-undefined|", omitted("encoding"));
process.stdout.write("effect-three-undefined|", "utf8", omitted("callback"));
process.stdout.write(Buffer.from("byte-two-undefined|"), omitted("byte-encoding"));
process.stdout.write(Buffer.from("byte-three-undefined|"), "utf8", omitted("byte-callback"));
const encoded = process.stdout.write(chunk(), encoding(), completion("encoded"));
console.log("encoded-return", encoded);
process.stdout.write("ZGVmYXVsdHxlbmNvZGluZ3w=", "base64");
process.stdout.write("é|", "binary");
process.stdout.write(Buffer.from("buffer|"), "utf16le", completion("buffer"));
process.stdout.write("default|", completion("default"));
process.stdout.write("zero|", () => console.log("callback:zero"));
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("tick"));
console.log("body-done");
process.stderr.write("stderr-body|", "utf8", (error) => {
process.stderr.write(`stderr-callback:${error === null}\n`);
});
+18
View File
@@ -85,4 +85,22 @@ describe("async_free detection over the IR", () => {
await surfaceOf("sig", `process.on("SIGINT", () => console.log("int"));\nconsole.log("armed");\n`),
).toContain("signal");
});
test("process output completion callbacks refuse as event-loop work", async () => {
expect(
await surfaceOf(
"stdout-write-callback",
`process.stdout.write("x", () => console.log("written"));\n`,
),
).toContain("process.stdout.write completion callbacks");
});
test("explicitly omitted process output callbacks remain async_free", async () => {
expect(
await surfaceOf(
"stdout-write-undefined",
`process.stdout.write("x", undefined);\nprocess.stdout.write("y", "utf8", undefined);\n`,
),
).toBeNull();
});
});