Answer 'in' on native Node handles from their declared members (#1949)

This commit is contained in:
Chris Tate
2026-10-24 14:51:03 -05:00
committed by GitHub
10 changed files with 128 additions and 0 deletions
@@ -119,6 +119,13 @@ export function emitChildProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr
B.line(`call void @scr_child_on_error(ptr ${child.name}, ptr ${cb.name}, ptr @${adapter})`);
return { name: "", type: e.type };
}
if (e.fn === "child.hasChannel") {
const child = host.emitExpr(e.args[0]!);
host.declare(`declare zeroext i1 @scr_child_ipc_present(ptr)`);
const out = B.tmp();
B.line(`${out} = call i1 @scr_child_ipc_present(ptr ${child.name})`);
return { name: out, type: e.type };
}
if (e.fn === "child.connected") {
const child = host.emitExpr(e.args[0]!);
host.declare(`declare zeroext i1 @scr_child_ipc_connected(ptr)`);
@@ -1918,6 +1918,30 @@ export function lowerInExpression(
};
}
}
// A child process has `send`, `disconnect` and `channel` only when it was
// created with an IPC channel (fork, or an "ipc" stdio entry), and keeps
// them after the channel closes: the runtime answers from the child.
if (recv.type.kind === "child" && CHILD_CHANNEL_MEMBERS.has(key))
return { kind: "libCall", fn: "child.hasChannel", args: [recv], type: BOOL, loc };
// A native Node handle (an http.Server, a socket, a child process...):
// a member Node's declarations give the class is on its prototype, so
// `"closeAllConnections" in server` answers true. Undeclared names can
// still be real (internal `_`-prefixed fields), so they keep the fence.
if (
NATIVE_HANDLE_KINDS.has(recv.type.kind) &&
nodeDeclaredMember(lowerer, lowerer.typeOf(expr.right), key)
) {
const answer: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
return isSafeToDiscard(recv)
? answer
: {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: recv, loc }],
result: answer,
type: BOOL,
loc,
};
}
if (recv.type.kind !== "record") {
lowerer.unsupported(
"SC1090",
@@ -2215,3 +2239,35 @@ function dynInstanceOfRefusal(lowerer: Lowerer, expr: ts.BinaryExpression): stri
const line = ts.getLineAndCharacterOfPosition(sf, diag.loc.start).line + 1;
return `${diag.message} [${diag.code} at ${diag.loc.file}:${line}]`;
}
/** IR kinds of native Node handles whose members come from Node's own
* declarations. */
const NATIVE_HANDLE_KINDS: ReadonlySet<IrType["kind"]> = new Set([
"netServer",
"netSocket",
"httpReq",
"httpRes",
"httpClientReq",
"child",
"dgramSocket",
"fsWatcher",
"http2Session",
"http2Stream",
]);
/** Child process members that exist only on a child with an IPC channel. */
const CHILD_CHANNEL_MEMBERS: ReadonlySet<string> = new Set(["send", "disconnect", "channel"]);
/** Whether every constituent of `t` declares a required member `key` in
* a declaration file (Node's @types/node). */
function nodeDeclaredMember(lowerer: Lowerer, t: ts.Type, key: string): boolean {
const parts = t.isUnionType() ? ts.constituentTypes(t) : [t];
return parts.every((part) => {
const prop = lowerer.checker.getPropertyOfType(part, key);
if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) !== 0) return false;
return (
prop.declarations.length > 0 &&
prop.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
);
});
}
@@ -2608,6 +2608,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
result: CHILD_T,
},
"child.connected": { argTypes: [CHILD_T], result: BOOL },
"child.hasChannel": { argTypes: [CHILD_T], result: BOOL },
"child.send": { argTypes: [CHILD_T, STRING], result: BOOL },
"child.sendCb": { argTypes: [CHILD_T, STRING, null], result: BOOL },
"child.disconnect": { argTypes: [CHILD_T], result: VOID },
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8290-child-channel-members/main.ts"
],
"diags": []
}
+3
View File
@@ -6669,6 +6669,9 @@ double scr_process_fork_target(double target_count) {
return target;
}
/* The child was created with an IPC channel; it keeps it until it is freed. */
bool scr_child_ipc_present(ScrChild *c) { return c->ipc != NULL; }
bool scr_child_ipc_connected(ScrChild *c) {
return c->ipc != NULL && c->ipc->connected;
}
+1
View File
@@ -176,6 +176,7 @@ void scr_child_release_v(void *p);
void scr_child_on_exit(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildExitFn fn);
void scr_child_on_close(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildExitFn fn);
void scr_child_on_error(ScrChild *c, ScrClosure *cb /*moves*/, ScrChildErrFn fn);
bool scr_child_ipc_present(ScrChild *c);
bool scr_child_ipc_connected(ScrChild *c);
bool scr_child_ipc_send(ScrChild *c, ScrStr *json);
bool scr_child_ipc_send_cb(ScrChild *c, ScrStr *json,
@@ -0,0 +1,28 @@
import { fork, spawn } from "node:child_process";
// `send`, `disconnect` and `channel` exist only on a child created with an
// IPC channel, and stay after the channel closes; the other members are
// on every child.
const describe = (label: string, child: ReturnType<typeof spawn>): string =>
`${label}: send ${"send" in child} disconnect ${"disconnect" in child} channel ${"channel" in child} kill ${"kill" in child} pid ${"pid" in child}`;
const lines: string[] = [];
const plain = spawn("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
lines.push(describe("spawn", plain));
const forked = fork(new URL("./worker.ts", import.meta.url), [], {
stdio: ["ignore", "ignore", "inherit", "ipc"],
});
lines.push(describe("fork", forked));
forked.once("message", () => {
forked.disconnect();
lines.push(describe("fork after disconnect", forked));
});
forked.once("exit", () => {
lines.push(describe("fork after exit", forked));
});
plain.once("exit", () => {
lines.push(describe("spawn after exit", plain));
});
process.once("exit", () => {
console.log(lines.sort().join("\n"));
});
@@ -0,0 +1 @@
process.send?.({ value: 1 });
@@ -0,0 +1,7 @@
import http from "node:http";
const port = Number(process.argv[2]);
http.get({ host: "127.0.0.1", port, path: "/" }, (res) => {
let body = "";
res.on("data", (c) => (body += c));
res.on("end", () => console.log(res.statusCode, body));
});
+18
View File
@@ -0,0 +1,18 @@
// `in` tests on native Node handles answer from Node's declared members.
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
const server = createServer((req, res) => {
console.log("request", "method" in req, "setHeader" in res, "writeHead" in res);
res.end("ok");
});
console.log("closeAllConnections" in server, "closeIdleConnections" in server, "listen" in server);
server.listen(0, "127.0.0.1", () => {
process.stderr.write(`PORT ${(server.address() as AddressInfo).port}\n`);
});
server.on("request", () => {
setTimeout(() => {
if ("closeAllConnections" in server) server.closeAllConnections();
server.close(() => console.log("closed"));
}, 10);
});