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 2c93f41e6..73366399a 100644 --- a/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts +++ b/packages/compiler/src/frontend/lowering/builtins/child-process-methods.ts @@ -15,6 +15,7 @@ import { STRING, VOID, canBoxFuncIntoDyn, + funcOf, } from "../../../ir/ir.js"; import { type IrLibFn } from "../../../ir/builtin-signatures.js"; import { @@ -157,7 +158,7 @@ export function lowerChildMethodCall( event === "exit" || event === "close" ? narrowedExitListenerParams(lowerer, listenerNode) : []; - const cb = + let cb = narrowed.length > 0 ? lowerWithCheckedParams(lowerer, listenerNode, narrowed) : lowerer.lowerExpr(call.arguments.nodes[1]!); @@ -218,17 +219,30 @@ export function lowerChildMethodCall( : "error listeners with more than one parameter (use (err) or ())", ); } + // The listener's parameter list survives the adapter below (it wraps + // the same closure shape); reading it once keeps the narrowing. + let listenerParams = cb.type.kind === "func" ? cb.type.params : []; if (cb.type.ret.kind !== "void") { - // `() => 5` IS assignable to a void-returning listener slot; the - // registry's call ABI is void, so a value-returning closure is - // fenced instead of being called through the wrong ABI. - lowerer.unsupported( - "SC1090", - call.arguments.nodes[1]!, - "listeners returning a value (make the callback body a block, or return nothing)", - ); + // Node drops every listener's return value: `on("close", async (…) => …)` + // ends in a discarded promise, and an annotated `(): number => 5` + // answers a number nobody reads. Adopt the slot's void through the + // closure adapter, which calls the listener and discards the result + // (the same shape every other void callback slot takes). A promise + // the listener returns is dropped exactly as Node drops it — a + // rejection still reaches the runtime's unhandled-rejection + // tracking, which fires at settlement, not at the drop. + const adopted = lowerer.coerceToExpected(cb, funcOf(listenerParams, VOID)); + if (adopted.type.kind !== "func" || adopted.type.ret.kind !== "void") { + lowerer.unsupported( + "SC1090", + call.arguments.nodes[1]!, + "listeners whose returned value cannot be discarded (make the callback body a block, or return nothing)", + ); + } + cb = adopted; + listenerParams = adopted.type.kind === "func" ? adopted.type.params : listenerParams; } - const param = cb.type.params[0]; + const param = listenerParams[0]; if (event === "exit" || event === "close") { const armsOk = param === undefined || @@ -249,7 +263,7 @@ export function lowerChildMethodCall( // The optional SECOND parameter is Node's signal: the terminating // signal's name as `Signals | null` — a string | null union here // (the interned adapter builds it at fire time). - const sigParam = cb.type.params[1]; + const sigParam = listenerParams[1]; const sigOk = sigParam === undefined || (sigParam.kind === "union" && diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-listener-return-values.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-listener-return-values.ts.json new file mode 100644 index 000000000..6dcedd13b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/child-listener-return-values.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/child-listener-return-values.ts" + ], + "diags": [] +} diff --git a/tests/corpus/child-listener-return-values.ts b/tests/corpus/child-listener-return-values.ts new file mode 100644 index 000000000..99e45157b --- /dev/null +++ b/tests/corpus/child-listener-return-values.ts @@ -0,0 +1,40 @@ +// A child-process listener's return value is dropped, as EventEmitter's +// emit drops it: an inferred value return, an annotated value return, and +// an async listener (whose promise Node never awaits). Each child is +// awaited before the next starts, so the print order is fixed and the +// differential run pins that returning a value changes nothing. +import { spawn } from "node:child_process"; + +function waitClose(child: ReturnType): Promise { + return new Promise((resolve) => { + child.on("close", () => resolve()); + }); +} + +async function main(): Promise { + const c1 = spawn("/bin/sh", ["-c", "exit 7"], { stdio: "ignore" }); + c1.on("exit", (code) => { + console.log("inferred return", code === null ? "signal" : code); + return code === null ? 0 : code; + }); + await waitClose(c1); + + const c2 = spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" }); + c2.on("exit", (): number => { + console.log("annotated return"); + return 5; + }); + await waitClose(c2); + + const c3 = spawn("/bin/sh", ["-c", "exit 3"], { stdio: "ignore" }); + c3.on("exit", () => console.log("sync exit listener")); + c3.on("close", async (code) => { + await Promise.resolve(); + console.log("async listener", code); + }); + await waitClose(c3); + + console.log("main done"); +} + +void main(); diff --git a/tests/diagnostics/child-process.ts b/tests/diagnostics/child-process.ts index 02b4349e8..6b2440277 100644 --- a/tests/diagnostics/child-process.ts +++ b/tests/diagnostics/child-process.ts @@ -20,11 +20,9 @@ spawn("true", [], options); spawn("printf", ["unsafe"], { stdio: "ignore", shell: true }); const c = spawn("true", [], { stdio: "ignore" }); -// `() => 5` IS assignable to a void-returning listener slot and now ADOPTS -// the slot's void (JS ignores the value); an ANNOTATED value-returning -// listener keeps its word and stays fenced — the registry calls listeners -// as void. -c.on("exit", (): number => 5); +// Value-returning listeners — inferred, annotated, or async — ADOPT the +// slot's void: Node drops whatever a listener returns, and the closure +// adapter discards it (tests/corpus/child-listener-return-values.ts). // Methods have no bound-value form — call on directly. const f = c.on; // The callback slice accepts inline utf8/maxBuffer options, but not all of diff --git a/tests/harness/__snapshots__/child-process.ts.txt b/tests/harness/__snapshots__/child-process.ts.txt index a1f2999b7..e32441f99 100644 --- a/tests/harness/__snapshots__/child-process.ts.txt +++ b/tests/harness/__snapshots__/child-process.ts.txt @@ -7,102 +7,95 @@ child-process.ts:20:17 - error SC2020: 'spawn with shell enabled and separate ar hint: put the complete shell command in the first argument and pass [] (Node deprecates unescaped shell argument concatenation) -child-process.ts:27:14 - error SC1090: listeners returning a value (make the callback body a block, or return nothing) is not supported yet +child-process.ts:27:7 - error SC2007: values of type '{ (event: "spawn", listener: () => void): ChildProcess; (event: "exit", listener: (code: number | null, signal: string | null) => void): ChildProcess; (event: "close", listener: (code: number | null, signal: string | null) => void): ChildProcess; (event: "error", listener: (err: Error) => void): ChildProcess; (event...' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature - 26 | // as void. - 27 | c.on("exit", (): number => 5); - | ^~~~~~~~~~~~~~~ - 28 | // Methods have no bound-value form — call on directly. - -child-process.ts:29:7 - error SC2007: values of type '{ (event: "spawn", listener: () => void): ChildProcess; (event: "exit", listener: (code: number | null, signal: string | null) => void): ChildProcess; (event: "close", listener: (code: number | null, signal: string | null) => void): ChildProcess; (event: "error", listener: (err: Error) => void): ChildProcess; (event...' cannot be compiled: the type declares multiple call signatures (overloads), and a compiled function value is always one concrete signature - - 28 | // Methods have no bound-value form — call on directly. - 29 | const f = c.on; + 26 | // Methods have no bound-value form — call on directly. + 27 | const f = c.on; | ^ - 30 | // The callback slice accepts inline utf8/maxBuffer options, but not all of + 28 | // The callback slice accepts inline utf8/maxBuffer options, but not all of hint: annotate the slot with the ONE signature the program actually calls (e.g. '(x: number) => string'), or wrap the overloaded function in a single-signature arrow -child-process.ts:29:11 - error SC1090: child methods as values (call 'on' directly) are not supported yet +child-process.ts:27:11 - error SC1090: child methods as values (call 'on' directly) are not supported yet - 28 | // Methods have no bound-value form — call on directly. - 29 | const f = c.on; + 26 | // Methods have no bound-value form — call on directly. + 27 | const f = c.on; | ^~~~ - 30 | // The callback slice accepts inline utf8/maxBuffer options, but not all of + 28 | // The callback slice accepts inline utf8/maxBuffer options, but not all of -child-process.ts:32:1 - error SC2020: 'execFile with 1 arguments' is part of the standard library types but has no scriptc lowering yet +child-process.ts:30:1 - error SC2020: 'execFile with 1 arguments' is part of the standard library types but has no scriptc lowering yet - 31 | // Node's options or the optional-callback overload family. - 32 | execFile("true"); + 29 | // Node's options or the optional-callback overload family. + 30 | execFile("true"); | ^~~~~~~~~~~~~~~~ - 33 | execFile("true", [], { cwd: "/tmp" }, () => {}); + 31 | execFile("true", [], { cwd: "/tmp" }, () => {}); hint: the supported callback forms are execFile(file, callback), execFile(file, args, callback), and execFile(file, args, { encoding: 'utf8', maxBuffer: N }, callback) -child-process.ts:33:24 - error SC2020: 'execFile option 'cwd'' is part of the standard library types but has no scriptc lowering yet +child-process.ts:31:24 - error SC2020: 'execFile option 'cwd'' is part of the standard library types but has no scriptc lowering yet - 32 | execFile("true"); - 33 | execFile("true", [], { cwd: "/tmp" }, () => {}); + 30 | execFile("true"); + 31 | execFile("true", [], { cwd: "/tmp" }, () => {}); | ^~~~~~~~~~~ - 34 | const bufferLimit = 1024; + 32 | const bufferLimit = 1024; hint: only encoding: 'utf8' and a pure numeric maxBuffer are supported in the callback form -child-process.ts:35:35 - error SC2020: 'execFile with a non-literal maxBuffer' is part of the standard library types but has no scriptc lowering yet +child-process.ts:33:35 - error SC2020: 'execFile with a non-literal maxBuffer' is part of the standard library types but has no scriptc lowering yet - 34 | const bufferLimit = 1024; - 35 | execFile("true", [], { maxBuffer: bufferLimit }, () => {}); + 32 | const bufferLimit = 1024; + 33 | execFile("true", [], { maxBuffer: bufferLimit }, () => {}); | ^~~~~~~~~~~ - 36 | const getEncoding = (): "utf8" => "utf8"; + 34 | const getEncoding = (): "utf8" => "utf8"; hint: pass a numeric literal or literal arithmetic (the native capture grows without enforcing this limit) -child-process.ts:37:34 - error SC2020: 'execFile with a non-literal utf8 encoding' is part of the standard library types but has no scriptc lowering yet +child-process.ts:35:34 - error SC2020: 'execFile with a non-literal utf8 encoding' is part of the standard library types but has no scriptc lowering yet - 36 | const getEncoding = (): "utf8" => "utf8"; - 37 | execFile("true", [], { encoding: getEncoding() }, () => {}); + 34 | const getEncoding = (): "utf8" => "utf8"; + 35 | execFile("true", [], { encoding: getEncoding() }, () => {}); | ^~~~~~~~~~~~~ - 38 | // Fork targets are part of the compiled graph and therefore must resolve at + 36 | // Fork targets are part of the compiled graph and therefore must resolve at hint: pass the literal 'utf8' or 'utf-8' (other expressions might have side effects) -child-process.ts:40:6 - error SC2020: 'fork with a runtime-valued module path' is part of the standard library types but has no scriptc lowering yet +child-process.ts:38:6 - error SC2020: 'fork with a runtime-valued module path' is part of the standard library types but has no scriptc lowering yet - 39 | // build time. The remaining channel is JSON-only and occupies stdio slot 3. - 40 | fork(process.argv[1]!); + 37 | // build time. The remaining channel is JSON-only and occupies stdio slot 3. + 38 | fork(process.argv[1]!); | ^~~~~~~~~~~~~~~~ - 41 | const staticWorker = new URL("./child-process.ts", import.meta.url); + 39 | const staticWorker = new URL("./child-process.ts", import.meta.url); hint: resolve a relative worker with new URL("./worker.ts", import.meta.url) or fileURLToPath(...) and bind it to a const if needed -child-process.ts:42:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet +child-process.ts:40:41 - error SC2020: 'fork with non-JSON serialization' is part of the standard library types but has no scriptc lowering yet - 41 | const staticWorker = new URL("./child-process.ts", import.meta.url); - 42 | fork(staticWorker, [], { serialization: "advanced" }); + 39 | const staticWorker = new URL("./child-process.ts", import.meta.url); + 40 | fork(staticWorker, [], { serialization: "advanced" }); | ^~~~~~~~~~ - 43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); + 41 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); hint: the static IPC channel supports Node's default serialization or serialization: "json" -child-process.ts:43:64 - error SC2020: 'fork without a fourth-slot IPC channel' is part of the standard library types but has no scriptc lowering yet +child-process.ts:41:64 - error SC2020: 'fork without a fourth-slot IPC channel' is part of the standard library types but has no scriptc lowering yet - 42 | fork(staticWorker, [], { serialization: "advanced" }); - 43 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); + 40 | fork(staticWorker, [], { serialization: "advanced" }); + 41 | fork(staticWorker, [], { stdio: ["ignore", "ignore", "ignore", "ignore"] }); | ^~~~~~~~ - 44 | // Keep each fence on its own statement so diagnostics remain site-specific. + 42 | // Keep each fence on its own statement so diagnostics remain site-specific. hint: the fourth stdio entry must be "ipc" -child-process.ts:48:21 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet +child-process.ts:46:21 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet - 47 | const pipedOut = piped.stdout!; - 48 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); + 45 | const pipedOut = piped.stdout!; + 46 | pipedOut.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 49 | pipedOut.setEncoding("utf8"); + 47 | pipedOut.setEncoding("utf8"); -child-process.ts:54:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet +child-process.ts:52:26 - error SC1090: untyped child-stream 'data' listeners whose setEncoding state is unclear at their registration sites (declare the chunk as 'Buffer' or 'string') are not supported yet - 53 | pipedErr.setEncoding("utf8"); - 54 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); + 51 | pipedErr.setEncoding("utf8"); + 52 | piped.stderr!.on("data", (chunk: unknown) => console.log(String(chunk))); | ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - 55 | \ No newline at end of file + 53 | \ No newline at end of file diff --git a/tests/harness/corpus-costs/child-listener-return-values.ts.json b/tests/harness/corpus-costs/child-listener-return-values.ts.json new file mode 100644 index 000000000..a8b98f531 --- /dev/null +++ b/tests/harness/corpus-costs/child-listener-return-values.ts.json @@ -0,0 +1 @@ +{ "plain": 6000, "san": 76500 }