From d8f3565ac496a544ad588d0f5fbd39c978dcfa5b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 29 Oct 2026 18:55:44 -0500 Subject: [PATCH] Call untyped write completion callbacks with the error argument A write completion callback that a JavaScript module returned reaches the stream lowering as an untyped value. Since the error-argument form of write callbacks, the lowering refused it with SC2020, so tests/corpus/8532-stream-saved-callback-receiver failed to compile. Such a callback is now converted to a function of the error argument. The stream calls it unbound with null after a successful write or with the write's error, and ignores what it returns, as Node does. --- .../src/frontend/lowering/lower-stream.ts | 10 +++++-- .../main.ts.json | 7 +++++ .../stream-dynamic-write-callback/main.ts | 28 +++++++++++++++++++ .../stream-dynamic-write-callback/report.js | 11 ++++++++ .../main.ts.json | 1 + 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json create mode 100644 tests/corpus/stream-dynamic-write-callback/main.ts create mode 100644 tests/corpus/stream-dynamic-write-callback/report.js create mode 100644 tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json diff --git a/packages/compiler/src/frontend/lowering/lower-stream.ts b/packages/compiler/src/frontend/lowering/lower-stream.ts index 5da73ed0a..1e9040d0e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stream.ts +++ b/packages/compiler/src/frontend/lowering/lower-stream.ts @@ -6966,11 +6966,15 @@ export function lowerStreamMethodCall( const lowerWriteCb = (cbArg: ts.Expression): IrExpr => { // The stream ignores what the callback returns (`() => count++`, a // stored function returning a value): the value is discarded. + // An untyped callback (a function a JavaScript module returned) is + // called unbound with the error argument, as Node calls it. const raw = lowerer.lowerExpr(cbArg); const cb = - raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 - ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) - : raw; + raw.type.kind === "dyn" + ? lowerer.coerceToExpected(raw, funcOf([DYN], VOID)) + : raw.type.kind === "func" && raw.type.ret.kind !== "void" && raw.type.params.length <= 1 + ? lowerer.coerceToExpected(raw, funcOf(raw.type.params, VOID)) + : raw; if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) { lowerer.noLowering( "write completion callbacks of this shape", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json new file mode 100644 index 000000000..424b6bc2f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/stream-dynamic-write-callback/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/stream-dynamic-write-callback/report.js", + "/tests/corpus/stream-dynamic-write-callback/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/stream-dynamic-write-callback/main.ts b/tests/corpus/stream-dynamic-write-callback/main.ts new file mode 100644 index 000000000..dc8e72995 --- /dev/null +++ b/tests/corpus/stream-dynamic-write-callback/main.ts @@ -0,0 +1,28 @@ +// Write completion callbacks from a JavaScript module run unbound with +// Node's arguments: null after a successful write, the _write error after a +// failed one, and the write-after-end error. Their return value is ignored. +import { Writable } from "node:stream"; +import { makeReporter } from "./report.js"; + +const lines: string[] = []; +const sink = new Writable({ + write(chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(chunk.toString() === "bad" ? new Error("rejected") : null); + }, +}); +sink.on("error", (error) => lines.push(`error event ${error.message}`)); +sink.write("good", makeReporter(lines, "good")); +sink.write("bad", "utf8", makeReporter(lines, "bad")); + +const ended = new Writable({ + write(_chunk: Buffer, _encoding: string, callback: (error?: Error | null) => void): void { + callback(); + }, +}); +ended.on("error", () => lines.push("ended error event")); +ended.end(); +ended.write("late", makeReporter(lines, "late")); + +setTimeout(() => { + for (const line of lines) console.log(line); +}, 5); diff --git a/tests/corpus/stream-dynamic-write-callback/report.js b/tests/corpus/stream-dynamic-write-callback/report.js new file mode 100644 index 000000000..4cd3c74f4 --- /dev/null +++ b/tests/corpus/stream-dynamic-write-callback/report.js @@ -0,0 +1,11 @@ +// A write completion callback built in JavaScript: it records how it was +// called. Module code is strict, so an unbound call sees `this` undefined. +export function makeReporter(lines, label) { + return function (...args) { + const error = args[0]; + lines.push( + `${label} args=${args.length} error=${error === null ? "null" : error === undefined ? "undefined" : error.message} this=${typeof this}`, + ); + return "ignored"; + }; +} diff --git a/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json b/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json new file mode 100644 index 000000000..7e594fe7b --- /dev/null +++ b/tests/harness/corpus-costs/stream-dynamic-write-callback/main.ts.json @@ -0,0 +1 @@ +{ "plain": 1833, "san": 2753 }