diff --git a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts index c1a6ea89b..2c93f41e6 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -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[], diff --git a/packages/compiler/src/frontend/lowering/handle-properties.ts b/packages/compiler/src/frontend/lowering/handle-properties.ts index ba098aab5..a3eefbc57 100644 --- a/packages/compiler/src/frontend/lowering/handle-properties.ts +++ b/packages/compiler/src/frontend/lowering/handle-properties.ts @@ -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 = 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( diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-stream-set-encoding-untyped-listener.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-stream-set-encoding-untyped-listener.cjs.json new file mode 100644 index 000000000..3256267c4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-stream-set-encoding-untyped-listener.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/child-stream-set-encoding-untyped-listener.cjs" + ], + "diags": [] +} diff --git a/tests/corpus/child-stream-set-encoding-untyped-listener.cjs b/tests/corpus/child-stream-set-encoding-untyped-listener.cjs new file mode 100644 index 000000000..db1d91393 --- /dev/null +++ b/tests/corpus/child-stream-set-encoding-untyped-listener.cjs @@ -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(' ')); +}); diff --git a/tests/harness/corpus-costs/child-stream-set-encoding-untyped-listener.cjs.json b/tests/harness/corpus-costs/child-stream-set-encoding-untyped-listener.cjs.json new file mode 100644 index 000000000..652ae8ea4 --- /dev/null +++ b/tests/harness/corpus-costs/child-stream-set-encoding-untyped-listener.cjs.json @@ -0,0 +1 @@ +{ "plain": 6521, "san": 6908 }