diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index 207347e31..080a6862c 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -22,7 +22,9 @@ import { DYN, STRING, type IrExpr, type IrType } from "../../ir/ir.js"; const JSON_HINT = "only JSON data (numbers, strings, booleans, null, arrays, plain objects of those, and unknown) crosses into Node.js in this version"; -/** The first part of `t` that is not JSON data, or null. */ +/** The first part of `t` that is not JSON data, or null. `seen` holds the + * records and unions already being checked, so a recursive type (a JSON + * value type that contains arrays or records of itself) ends there. */ function nonJsonPart(lowerer: Lowerer, t: IrType, seen: Set): IrType | null { switch (t.kind) { case "f64": @@ -35,8 +37,8 @@ function nonJsonPart(lowerer: Lowerer, t: IrType, seen: Set): IrType | n case "array": return nonJsonPart(lowerer, t.elem, seen); case "record": { - if (seen.has(t.shapeId)) return null; - seen.add(t.shapeId); + if (seen.has(`record:${t.shapeId}`)) return null; + seen.add(`record:${t.shapeId}`); const shape = lowerer.shapes.get(t.shapeId); if (shape === undefined) return t; for (const field of shape.fields) { @@ -46,6 +48,8 @@ function nonJsonPart(lowerer: Lowerer, t: IrType, seen: Set): IrType | n return shape.indexValue === undefined ? null : nonJsonPart(lowerer, shape.indexValue, seen); } case "union": { + if (seen.has(`union:${t.unionId}`)) return null; + seen.add(`union:${t.unionId}`); const def = lowerer.unions.get(t.unionId); if (def === undefined) return t; for (const arm of def.arms) { diff --git a/tests/fixtures/node-island/helper.cjs b/tests/fixtures/node-island/helper.cjs index 317a5b65b..903532b0c 100644 --- a/tests/fixtures/node-island/helper.cjs +++ b/tests/fixtures/node-island/helper.cjs @@ -1,2 +1,3 @@ "use strict"; exports.answer = () => 42; +exports.echo = (value) => value; diff --git a/tests/fixtures/node-island/lifecycle.ts b/tests/fixtures/node-island/lifecycle.ts index 1185e0674..f18ec640d 100644 --- a/tests/fixtures/node-island/lifecycle.ts +++ b/tests/fixtures/node-island/lifecycle.ts @@ -4,8 +4,12 @@ import { readFileSync } from "node:fs"; import { callSync } from "@scriptc/node"; const mode = process.argv[2] ?? "call"; -const mapped = (): boolean => - process.platform !== "linux" || readFileSync("/proc/self/maps", "utf8").includes("libnode"); +// Whether libnode is mapped into the process, from /proc/self/maps; other +// platforms report "unknown". +const mapped = (): boolean | string => + process.platform === "linux" + ? readFileSync("/proc/self/maps", "utf8").includes("libnode") + : "unknown"; console.log("mapped before", mapped()); if (mode !== "never") { console.log(callSync("./helper.cjs", "answer")); diff --git a/tests/fixtures/node-island/recursive-json.ts b/tests/fixtures/node-island/recursive-json.ts new file mode 100644 index 000000000..90d9c757f --- /dev/null +++ b/tests/fixtures/node-island/recursive-json.ts @@ -0,0 +1,9 @@ +// A recursive JSON value type as an argument and a result of a Node island +// call (tests/harness/node-island.test.ts). +import { callSync } from "@scriptc/node"; + +type Json = number | string | boolean | null | Json[] | { [key: string]: Json }; + +const input: Json = { list: [1, "two", [true, null]] }; +const output = callSync("./helper.cjs", "echo", input); +console.log(JSON.stringify(output)); diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 0ee5c0fa7..47a68e290 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -6,11 +6,11 @@ * (6800-6803) pins the call semantics against Node. Needs a libnode pack * in SCRIPTC_LIBNODE; required in the Linux x64 CI lanes (libnode.ts). */ import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import { beforeAll, describe, expect, test } from "vitest"; -import { compile } from "@scriptc/compiler"; +import { compile, deserializeModule } from "@scriptc/compiler"; import { definedFunctionNames, nodeIslandSymbols } from "../../scripts/zero-cost-compare.mjs"; import { libnodePack } from "./libnode.js"; @@ -31,6 +31,11 @@ interface Run { let binary = ""; +// The fixture reads whether libnode is mapped from /proc/self/maps; other +// platforms print "unknown". +const before = `mapped before ${process.platform === "linux" ? "false" : "unknown"}`; +const after = `mapped after ${process.platform === "linux" ? "true" : "unknown"}`; + async function run( path: string, args: string[], @@ -103,12 +108,12 @@ describe.skipIf(skip)("Node islands in-process", () => { test("loads Node on the first call, and only then", async () => { expect(await run(binary, [])).toEqual({ - stdout: "mapped before false\n42\nmapped after true\ndone\n", + stdout: `${before}\n42\n${after}\ndone\n`, stderr: "", code: 0, }); expect(await run(binary, ["never"])).toEqual({ - stdout: "mapped before false\ndone\n", + stdout: `${before}\ndone\n`, stderr: "", code: 0, }); @@ -117,7 +122,7 @@ describe.skipIf(skip)("Node islands in-process", () => { test("process.exit after loading Node keeps its code", async () => { const result = await run(binary, ["exit"]); expect(result.code).toBe(5); - expect(result.stdout).toBe("mapped before false\n42\nmapped after true\n"); + expect(result.stdout).toBe(`${before}\n42\n${after}\n`); }); test("beside packaging carries its own pack", async () => { @@ -126,14 +131,14 @@ describe.skipIf(skip)("Node islands in-process", () => { const env = { ...process.env }; delete env["SCRIPTC_LIBNODE"]; const result = await run(beside, [], env); - expect(result.stdout).toBe("mapped before false\n42\nmapped after true\ndone\n"); + expect(result.stdout).toBe(`${before}\n42\n${after}\ndone\n`); expect(result.code).toBe(0); }, 300_000); test("a missing pack fails the first call", async () => { const result = await run(binary, [], { ...process.env, SCRIPTC_LIBNODE: "/nonexistent/pack" }); expect(result.code).toBe(1); - expect(result.stdout).toBe("mapped before false\n"); + expect(result.stdout).toBe(`${before}\n`); expect(result.stderr).toMatch( /^scriptc: cannot load Node\.js for this program's Node islands: .*\/nonexistent\/pack\/libscr_node_bridge\.(so|dylib).*\n$/, ); @@ -184,3 +189,20 @@ describe.skipIf(skip)("Node islands in-process", () => { expect(conflicting).toEqual([]); }); }); + +// Lowering checks every argument and result type for JSON data; IR output +// needs no libnode pack. +test("recursive JSON types cross into Node", async () => { + const dir = join(outDir, "recursive-json"); + mkdirSync(dir, { recursive: true }); + const outPath = join(dir, "main.ir.json"); + const result = await compile(join(repoRoot, "tests/fixtures/node-island/recursive-json.ts"), { + outPath, + outDir: dir, + outputKind: "ir", + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const ir = JSON.stringify(deserializeModule(readFileSync(outPath, "utf8"))); + expect(ir).toContain("nodeIsland.callSync"); +}, 300_000);