Pass console lines and byte chunks to a patched stdio write

Node's console writes each formatted line with stream.write(line,
errorHandler), so a replaced process.stdout.write sees a string and a
function; a Buffer write arrives as a Buffer. The stdio write hook carried
only a `wide` bit, so every intercepted chunk became a JavaScript string and
console lines carried a single argument.

Replace that bit with a `flags` int (SCR_STDIO_HOOK_WIDE, _CONSOLE, _BYTES)
and keep the hook signature at four parameters, so the console write path and
the byte paths differ only in what they pass. scr_stdio_intercept now builds a
chunk instead of a string, boxes SCR_STDIO_HOOK_BYTES as a Buffer, and appends
a console error handler for SCR_STDIO_HOOK_CONSOLE. Text callers of the old
string entry point move to a new scr_stdio_write_text.

Corpus: stdout-patch-console-arguments.js.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 20:08:13 -07:00
co-authored by Claude Code
parent d1abf69149
commit 7dbcf07fe0
7 changed files with 89 additions and 15 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/stdout-patch-console-arguments.js"
],
"diags": []
}
+13 -5
View File
@@ -203,7 +203,7 @@ void scr_init(void) {
* the stream differs. */
/* A real definition lets relocatable library links localize this hook;
* a tentative common symbol stays externally visible on Mach-O. */
SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide) = NULL;
SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, int flags) = NULL;
SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len) = NULL;
SCR_TL bool scr_stdin_fed = false;
@@ -420,7 +420,7 @@ static void scr_console_write(int fd, size_t n, const ScrLogArg *args, bool glob
}
if (scr_stdio_write_hook) {
/* A patched write receives the JavaScript string, lone surrogates kept. */
scr_stdio_write_hook(fd, line, used, wide);
scr_stdio_write_hook(fd, line, used, (wide ? SCR_STDIO_HOOK_WIDE : 0) | SCR_STDIO_HOOK_CONSOLE);
if (scr_exc_pending()) scr_exc_clear();
} else {
if (wide) scr_wtf8_to_utf8(line, used);
@@ -481,8 +481,16 @@ int scr_stdio_write_raw(int fd, const void *data, size_t len) {
#endif
}
/* A byte chunk (process.stdout.write(buffer), a stream piped to stdout). */
bool scr_stdio_write(int fd, const void *data, size_t len) {
if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, false);
if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, SCR_STDIO_HOOK_BYTES);
(void)scr_stdio_write_raw(fd, data, len);
return true;
}
/* A string chunk that is plain UTF-8. */
bool scr_stdio_write_text(int fd, const void *data, size_t len) {
if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, data, len, 0);
(void)scr_stdio_write_raw(fd, data, len);
return true;
}
@@ -490,7 +498,7 @@ bool scr_stdio_write(int fd, const void *data, size_t len) {
/* A string chunk: a patched write receives the string itself; the stream
* receives its UTF-8, each lone surrogate as U+FFFD. */
static __attribute__((noinline)) bool scr_stdio_write_wide(int fd, const ScrStr *s) {
if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, s->data, s->len, true);
if (scr_stdio_write_hook) return scr_stdio_write_hook(fd, s->data, s->len, SCR_STDIO_HOOK_WIDE);
char *owned;
const char *bytes = scr_str_utf8(s, &owned);
(void)scr_stdio_write_raw(fd, bytes, s->len);
@@ -500,5 +508,5 @@ static __attribute__((noinline)) bool scr_stdio_write_wide(int fd, const ScrStr
bool scr_stdio_write_str(int fd, const ScrStr *s) {
if (__builtin_expect(scr_wtf8_any(), 0) && !s->ascii) return scr_stdio_write_wide(fd, s);
return scr_stdio_write(fd, s->data, s->len);
return scr_stdio_write_text(fd, s->data, s->len);
}
+2 -2
View File
@@ -489,8 +489,8 @@ void scr_console_clear(void) {
if (!scr_process_is_tty(1)) return;
const char *term = getenv("TERM");
if (term && strcmp(term, "dumb") == 0) return;
scr_stdio_write(1, "\x1b[1;1H", 6);
scr_stdio_write(1, "\x1b[0J", 4);
scr_stdio_write_text(1, "\x1b[1;1H", 6);
scr_stdio_write_text(1, "\x1b[0J", 4);
}
/* console.dir over a dyn value: inspect with custom inspection off. The
+28 -5
View File
@@ -1507,9 +1507,19 @@ static ScrDyn *scr_stdio_invoke(void *ptr, ScrDyn *self, const char *name,
return result;
}
static bool scr_stdio_intercept(int fd, const void *data, size_t length, bool wide) {
/* Node's console error handler for a stream write (createWriteErrorHandler):
* console swallows write errors, so it ignores its argument. */
static ScrDyn *scr_stdio_console_error_handler(ScrClosure *cb, ScrDyn *const *args, size_t argc) {
(void)cb; (void)args; (void)argc;
return scr_dyn_retain(scr_dyn_undefined());
}
static SCR_TL ScrDyn *scr_stdio_console_handler = NULL;
static bool scr_stdio_intercept(int fd, const void *data, size_t length, int flags) {
ScrStdio *stream = &scr_stdio_streams[fd == 2 ? 2 : 1];
if (!stream->write || scr_dyn_strict_eq(stream->write, scr_stdio_methods[STDIO_WRITE])) {
bool wide = (flags & SCR_STDIO_HOOK_WIDE) != 0;
char *owned = NULL;
if (wide && scr_wtf8_find_surrogate(data, length)) {
owned = malloc(length ? length : 1);
@@ -1522,12 +1532,22 @@ static bool scr_stdio_intercept(int fd, const void *data, size_t length, bool wi
free(owned);
return true;
}
ScrStr *text = scr_str_new(data, length);
ScrDyn *chunk = scr_dyn_new_str(text);
scr_str_release(text);
ScrDyn *chunk;
if (flags & SCR_STDIO_HOOK_BYTES) {
ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)length);
memcpy(b->data, data, length);
chunk = scr_dyn_new_chunk(b);
scr_bytes_release(b);
} else {
ScrStr *text = scr_str_new(data, length);
chunk = scr_dyn_new_str(text);
scr_str_release(text);
}
ScrDyn *argv[2] = {chunk, scr_stdio_console_handler};
size_t argc = (flags & SCR_STDIO_HOOK_CONSOLE) ? 2 : 1;
scr_dyn_this_push(stream, SCR_DYNH_STDIO);
ScrDyn *fn = scr_dyn_retain(stream->write);
ScrDyn *result = scr_dyn_call(fn, &chunk, 1, "write");
ScrDyn *result = scr_dyn_call(fn, argv, argc, "write");
scr_dyn_release(fn);
scr_dyn_this_pop();
bool accepted = result && scr_dyn_truthy(result);
@@ -1562,6 +1582,8 @@ static void scr_stdio_cleanup(void) {
scr_dyn_release(scr_stdio_methods[i]);
scr_stdio_methods[i] = NULL;
}
scr_dyn_release(scr_stdio_console_handler);
scr_stdio_console_handler = NULL;
scr_stdio_initialized = false;
}
@@ -1582,6 +1604,7 @@ ScrDyn *scr_process_stdio(double fd) {
scr_box_set_f64(cb->caps[0], i);
scr_stdio_methods[i] = scr_dyn_new_func(cb, scr_stdio_method, i == STDIO_WRITE ? 3 : 1, "", scr_stdio_names[i]);
}
scr_stdio_console_handler = scr_dyn_new_func(scr_closure_new(NULL, 0), scr_stdio_console_error_handler, 1, "", "");
scr_stdio_write_hook = scr_stdio_intercept;
}
return scr_dyn_new_handle(&scr_stdio_streams[fd == 0 ? 0 : fd == 2 ? 2 : 1], SCR_DYNH_STDIO);
+10 -3
View File
@@ -71,9 +71,16 @@ ScrStr *scr_process_cwd(void); /* +1 fresh (getcwd) */
bool scr_stdio_write(int fd, const void *data, size_t len);
bool scr_stdio_write_str(int fd, const ScrStr *s);
int scr_stdio_write_raw(int fd, const void *data, size_t len);
/* `wide` marks string bytes that may hold lone surrogates, which become
* U+FFFD when they reach the stream itself. */
extern SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, bool wide);
/* `flags` describes the chunk a patched write receives: SCR_STDIO_HOOK_WIDE
* marks string bytes that may hold lone surrogates (they become U+FFFD when
* they reach the stream itself), SCR_STDIO_HOOK_CONSOLE a console line
* (Node's console passes its error handler as the second argument), and
* SCR_STDIO_HOOK_BYTES a byte chunk (the replacement receives a Buffer). */
enum { SCR_STDIO_HOOK_WIDE = 1, SCR_STDIO_HOOK_CONSOLE = 2, SCR_STDIO_HOOK_BYTES = 4 };
extern SCR_TL bool (*scr_stdio_write_hook)(int fd, const void *data, size_t len, int flags);
/* A string chunk given as bytes (UTF-8, no lone surrogates): what
* scr_stdio_write was for callers that write text. */
bool scr_stdio_write_text(int fd, const void *data, size_t len);
/* A worker created with the stdout or stderr option: answers true when it
* took the bytes for fd 1 or 2 instead of the process descriptor. */
extern SCR_TL bool (*scr_stdio_capture)(int fd, const void *data, size_t len);
@@ -0,0 +1,29 @@
// What a replaced process.stdout.write receives from console: Node's
// console writes each formatted line with stream.write(line, errorHandler),
// so the replacement sees a string and a function as its second argument
// for console.log, info, debug, dir, count, table and grouped lines alike;
// a direct write passes exactly its own arguments. The error handler accepts
// a null error.
const original = process.stdout.write;
const seen = [];
process.stdout.write = function (chunk, encodingOrCallback, callback) {
const kind = typeof chunk === "string" ? "string" : Buffer.isBuffer(chunk) ? "Buffer" : chunk.constructor.name;
seen.push(`${kind}:${JSON.stringify(String(chunk))}:${typeof encodingOrCallback}:${typeof callback}`);
if (typeof encodingOrCallback === "function") encodingOrCallback(null);
return true;
};
console.log("line", 1);
console.info("info");
console.debug("debug");
console.log("%s=%d", "x", 2);
console.dir({ a: 1 });
console.count();
console.group("group");
console.log("inside");
console.groupEnd();
console.table([{ a: 1 }]);
process.stdout.write("direct");
process.stdout.write("hex", "utf8");
process.stdout.write(Buffer.from("bytes"));
process.stdout.write = original;
console.log(seen.join("\n"));
@@ -0,0 +1 @@
{ "plain": 5460, "san": 7830 }