Pass strings to an untyped 'data' listener on a child stream after setEncoding (#2602)

This commit is contained in:
Chris Tate
2026-10-30 01:54:58 -05:00
committed by GitHub
5 changed files with 114 additions and 2 deletions
@@ -824,6 +824,32 @@ function childStreamEncodedAt(
return before;
}
/** Whether the program calls `setEncoding` on the stream `receiver`
* names: a call whose receiver has the same access path, anywhere in the
* file. Its 'data' chunks can then be strings. */
export function childStreamSetsEncoding(lowerer: Lowerer, receiver: ts.Expression): boolean {
const key = accessPathOf(lowerer, receiver);
if (key === null) return false;
let found = false;
const visit = (node: ts.Node): void => {
if (found) return;
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === "setEncoding"
) {
const path = accessPathOf(lowerer, node.expression.expression);
if (path !== null && path.root === key.root && path.path === key.path) {
found = true;
return;
}
}
ts.forEachChild(node, visit);
};
visit(ts.getSourceFileOfNode(receiver));
return found;
}
/** An inline 'exit'/'close' listener may declare `(code: number, signal:
* string)`: TypeScript accepts it through ChildProcess's catch-all `on`
* overload, while Node passes `number | null` and `Signals | null`. Those
@@ -850,7 +876,7 @@ function narrowedExitListenerParams(lowerer: Lowerer, listener: ts.Expression):
/** Lowers an inline listener with the given parameters bound as
* checked-dynamic values typed `unknown` for the body. */
function lowerWithCheckedParams(
export function lowerWithCheckedParams(
lowerer: Lowerer,
listener: ts.Expression,
symbols: readonly ts.Symbol[],
@@ -16,6 +16,10 @@ import { DYN, STRING, VOID } from "../../ir/ir.js";
import { everyStmt } from "../../ir/traverse.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { ENTRY_NAME, type Lowerer } from "./lowerer.js";
import {
childStreamSetsEncoding,
lowerWithCheckedParams,
} from "./builtins/child-process-methods.js";
/** `child.name = value` in a JavaScript file over a typed child: the write
* lands in the child's property table through its checked-dynamic box. */
@@ -235,12 +239,50 @@ export function lowerChildPropertyCall(
recv,
method: access.name.text,
calleeName: ts.getTextOfNode(access),
args: call.arguments.nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
args: call.arguments.nodes.map((arg, i) =>
i === 1 && dataListenerOfEncodedStream(lowerer, call, access)
? lowerer.coerceToExpected(lowerUntypedChunkListener(lowerer, arg), DYN)
: lowerer.lowerExprExpecting(arg, DYN),
),
type: DYN,
loc,
};
}
const LISTENER_METHODS: ReadonlySet<string> = new Set([
"on",
"once",
"addListener",
"prependListener",
"prependOnceListener",
]);
/** `stream.on("data", (chunk) => ...)` with an unannotated chunk, on a
* stream the program calls `setEncoding` on. The declarations type the
* chunk as a Buffer, but after `setEncoding` Node passes strings. */
function dataListenerOfEncodedStream(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): boolean {
const [event, listener] = call.arguments.nodes;
if (!LISTENER_METHODS.has(access.name.text) || event === undefined || listener === undefined)
return false;
if (!ts.isStringLiteralLike(event) || event.text !== "data") return false;
if (!ts.isArrowFunction(listener) && !ts.isFunctionExpression(listener)) return false;
const chunk = listener.parameters.nodes[0];
if (chunk === undefined || chunk.type !== undefined || !ts.isIdentifier(chunk.name)) return false;
return childStreamSetsEncoding(lowerer, access.expression);
}
/** The listener with its chunk parameter bound as a checked-dynamic value,
* which holds a string or a Buffer as Node passes it. */
function lowerUntypedChunkListener(lowerer: Lowerer, listener: ts.Expression): IrExpr {
const fn = listener as ts.ArrowFunction | ts.FunctionExpression;
const symbol = lowerer.checker.getSymbolAtLocation(fn.parameters.nodes[0]!.name);
return symbol ? lowerWithCheckedParams(lowerer, listener, [symbol]) : lowerer.lowerExpr(listener);
}
/** `child.name` in JavaScript over an untyped receiver holding a child, or
* for a name the program adds to children. */
export function lowerChildPropertyRead(
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/child-stream-set-encoding-untyped-listener.cjs"
],
"diags": []
}
@@ -0,0 +1,37 @@
'use strict';
// An unannotated 'data' listener on a child stream that setEncoding
// switched to strings receives strings, as in Node's
// test-child-process-default-options: the declarations type the chunk as
// a Buffer. A stream without setEncoding keeps delivering Buffers, and a
// listener added before the setEncoding call also receives strings.
const { spawn } = require('child_process');
process.env.HELLO = 'WORLD';
const child = spawn('/usr/bin/env', [], {});
let response = '';
const kinds = new Set();
child.stdout.setEncoding('utf8');
child.stdout.on('data', function (chunk) {
kinds.add(typeof chunk);
response += chunk;
});
const raw = spawn('printf', ['raw bytes']);
let rawText = '';
raw.stdout.on('data', (chunk) => {
kinds.add(Buffer.isBuffer(chunk) ? 'buffer' : typeof chunk);
rawText += chunk.toString('utf8', 0, chunk.length);
});
const early = spawn('printf', ['early listener']);
let earlyText = '';
early.stdout.on('data', (chunk) => {
earlyText += chunk;
kinds.add(`early ${typeof chunk}`);
});
early.stdout.setEncoding('utf8');
process.on('exit', () => {
console.log(response.includes('HELLO=WORLD'), JSON.stringify(rawText), JSON.stringify(earlyText));
console.log([...kinds].sort().join(' '));
});
@@ -0,0 +1 @@
{ "plain": 6521, "san": 6908 }