mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-06 08:23:19 +08:00
Discard a child-process listener's return value through the void adapter
`child.on("close", async (code) => …)` lowers to a closure returning
Promise<void>, and an annotated `(): number => 5` returns a number. Node
drops every listener's return value: EventEmitter ignores it, and a
returned promise still surfaces its rejection through the runtime's
unhandled-rejection tracking, which fires at settlement, not at the drop.
The listener slot is void, so the closure now adopts it with
coerceToExpected(cb, funcOf(params, VOID)) — the adapter calls the
listener and discards the answer, the shape every other void callback
slot already takes. A returned value that cannot be discarded still
refuses with SC1090.
tests/diagnostics/child-process.ts drops the annotated-listener fence
(Node drops that value too) and its snapshot is re-recorded; the new
corpus program pins the inferred, annotated and async listener shapes
against Node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
62094b0832
commit
69f3ae8dd9
@@ -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" &&
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/child-listener-return-values.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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<typeof spawn>): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
child.on("close", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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();
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
53 |
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 6000, "san": 76500 }
|
||||
Reference in New Issue
Block a user