diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index c07b0b96d..292e1cacc 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -976,6 +976,8 @@ export const LIB_FN_SYMS: Record = { "nodeIsland.classMethodsOf": "scr_node_island_class_methods_of", "nodeIsland.instanceofGlobal": "scr_node_island_instanceof_global", "nodeIsland.isHandle": "scr_ni_is_handle", + "nodeIsland.copyArrays": "scr_node_island_copy_arrays", + "nodeIsland.awaitCopy": "scr_node_island_await_copy", "dyn.keySet": "scr_dyn_key_set", "dyn.keySetComputed": "scr_dyn_key_set_computed", "dyn.keyDelete": "scr_dyn_key_delete", @@ -1831,6 +1833,7 @@ export const USES_TIMERS_LIB_FNS = new Set([ "nodeIsland.require", "nodeIsland.defer", "nodeIsland.import", + "nodeIsland.awaitCopy", "nodeIsland.get", "nodeIsland.getKey", "nodeIsland.set", diff --git a/packages/compiler/src/frontend/lowering/lower-node-island.ts b/packages/compiler/src/frontend/lowering/lower-node-island.ts index d192f2460..dbcc72f13 100644 --- a/packages/compiler/src/frontend/lowering/lower-node-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-node-island.ts @@ -500,6 +500,20 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr }; return mapped.inner.kind === "dyn" ? promise : lowerer.coerceToExpected(promise, mapped); } + // Arrays of copied values (or arrays of those) cross as copies of the + // island array as an awaited promise's value, and directly where they do + // not stay handles (below); the checked cast then meets the copy. A + // native array that crossed in comes back as itself. + if (mapped.kind === "promise" && copiedArrayType(lowerer, mapped.inner)) { + const promise: IrExpr = { + kind: "libCall", + fn: "nodeIsland.awaitCopy", + args: [raw], + type: { kind: "promise", inner: DYN }, + loc: raw.loc, + }; + return lowerer.coerceToExpected(promise, mapped); + } // NI-5: a native value the program passed in comes back as itself (an // export); the checked cast unboxes it, and anything else fails the cast // (types don't lie). @@ -509,6 +523,17 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr // operations follow the value). if (!copiedType(lowerer, mapped) && containerType(lowerer, tsType) && handleUseOk(lowerer, node)) return raw; + // Otherwise an array of copied values is a copy (above). + if (copiedArrayType(lowerer, mapped)) { + const copy: IrExpr = { + kind: "libCall", + fn: "nodeIsland.copyArrays", + args: [raw], + type: DYN, + loc: raw.loc, + }; + return lowerer.coerceToExpected(copy, mapped); + } if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType)) return lowerer.coerceToExpected(raw, mapped); if (!copiedType(lowerer, mapped)) @@ -520,6 +545,12 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr return lowerer.coerceToExpected(raw, mapped); } +/** An array whose elements are copied values or such arrays. */ +function copiedArrayType(lowerer: Lowerer, t: IrType): boolean { + if (t.kind !== "array") return false; + return copiedType(lowerer, t.elem) || copiedArrayType(lowerer, t.elem); +} + /** Whether a value of IR type `t` is copied at the boundary: primitives, * strings, null/undefined, and unions of those. */ function copiedType(lowerer: Lowerer, t: IrType): boolean { diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index 56f3259ec..ae21c2010 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -28,6 +28,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "nodeIsland.defineField", "nodeIsland.promisify", "nodeIsland.classMethodsOf", + "nodeIsland.copyArrays", // Node's argument, cipher-name, IV, tag-length and key-length errors. "crypto.cipherNew", // Node's curve, key, point, encoding and format errors. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 585f7798d..5de0d5e17 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -226,6 +226,10 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "nodeIsland.instanceofGlobal": { argTypes: [DYN, STRING], result: BOOL }, /** Whether a dyn value is an island handle (never loads Node). */ "nodeIsland.isHandle": { argTypes: [DYN], result: BOOL }, + /** Island arrays of copied values as copies (a result, or an awaited + * promise's value). */ + "nodeIsland.copyArrays": { argTypes: [DYN], result: DYN }, + "nodeIsland.awaitCopy": { argTypes: [DYN], result: { kind: "promise", inner: DYN } }, /** Keyed WRITE on a dyn value — `h.onDone = cb` / `h["k"] = v` on a * checked-dynamic object (args: receiver, key string, value — all * borrowed; the runtime copies the key and retains the value in). An diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6837-node-island-clone-and-arrays/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6837-node-island-clone-and-arrays/main.ts.json new file mode 100644 index 000000000..6c99583f1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6837-node-island-clone-and-arrays/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6837-node-island-clone-and-arrays/main.ts" + ], + "diags": [] +} diff --git a/packages/libnode-common/bridge/scr_node_bridge.cc b/packages/libnode-common/bridge/scr_node_bridge.cc index a2a708467..fc3c33af9 100644 --- a/packages/libnode-common/bridge/scr_node_bridge.cc +++ b/packages/libnode-common/bridge/scr_node_bridge.cc @@ -183,6 +183,16 @@ bool TakeFunction(Isolate *isolate, Local context, Local from, return true; } +uint32_t ExportIdOf(Isolate *isolate, Local context, Local v); + +// isExport(value): whether a value is the representative of a native value +// (NI-5), for the bootstrap's structuredClone of native data. +void IsExport(const FunctionCallbackInfo &info) { + Isolate *isolate = info.GetIsolate(); + info.GetReturnValue().Set(info.Length() > 0 && + ExportIdOf(isolate, isolate->GetCurrentContext(), info[0]) != 0); +} + char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *argv0, uintptr_t stack_limit) { if (g_setup) return nullptr; @@ -244,6 +254,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar SetMethod(context, native, "sharedTable", SharedTable); SetMethod(context, native, "sharedGet", SharedGet); SetMethod(context, native, "sharedTdz", SharedTdz); + SetMethod(context, native, "isExport", IsExport); global->Set(context, Utf8(isolate, "__scriptcNodeIslandHost"), native).Check(); TryCatch try_catch(isolate); if (node::LoadEnvironment(g_setup->env(), bootstrap).IsEmpty()) { diff --git a/packages/runtime/src/scr_node_island.h b/packages/runtime/src/scr_node_island.h index 99eab37eb..5abcfdb3c 100644 --- a/packages/runtime/src/scr_node_island.h +++ b/packages/runtime/src/scr_node_island.h @@ -85,6 +85,9 @@ bool scr_ni_is_function(const ScrDyn *value); /* an island handle that is callab ScrStr *scr_ni_inspect(const ScrDyn *value, double recurse, double depth); ScrDyn *scr_ni_own_keys(ScrDyn *obj, int mode); /* 0 Object.keys, 1 Reflect.ownKeys */ ScrPromise *scr_ni_await(ScrDyn *value); +/* Island arrays as copies for results typed as arrays of copied values. */ +ScrDyn *scr_node_island_copy_arrays(ScrDyn *value); +ScrPromise *scr_node_island_await_copy(ScrDyn *value); uint32_t scr_ni_live_handles(void); /* live import-table entries (leak tests) */ /* Between the two island units. */ diff --git a/packages/runtime/src/scr_node_island_handle.c b/packages/runtime/src/scr_node_island_handle.c index 3374bfe9d..c09f19313 100644 --- a/packages/runtime/src/scr_node_island_handle.c +++ b/packages/runtime/src/scr_node_island_handle.c @@ -1237,6 +1237,58 @@ ScrDyn *scr_ni_value_in(const ScrNodeValue *v) { return d != NULL ? d : scr_dyn_retain(scr_dyn_undefined()); } +/* ── island arrays as copies (results typed as arrays of copied values) ─ + * An island array whose declared native type is an array of primitives or + * strings (or arrays of those) crosses as a copy of its elements, the + * values the checked cast then meets; nested island arrays copy too, any + * other value (a native export coming back, an island object) is itself. */ +ScrDyn *scr_node_island_copy_arrays(ScrDyn *value) { + ScrNodeRef *ref = nh_ref_of(value); + if (ref == NULL || (ref->flags & SCR_NVF_ARRAY) == 0) return scr_dyn_retain(value); + ScrDyn *len = scr_ni_get_str(value, "length", 6); + if (len == NULL) return NULL; + double n = len->kind == SCR_DYN_NUM ? len->v.num : 0; + scr_dyn_release(len); + ScrDyn *out = scr_dyn_new_arr(); + for (double i = 0; i < n; i++) { + ScrDyn *key = scr_dyn_new_num(i); + ScrDyn *element = scr_ni_get(value, key); + scr_dyn_release(key); + ScrDyn *copy = element != NULL ? scr_node_island_copy_arrays(element) : NULL; + scr_dyn_release(element); + if (copy == NULL) { + scr_dyn_release(out); + return NULL; + } + scr_dyn_arr_push(out, copy); + } + return out; +} + +static void nh_copy_fulfilled(ScrPromise *dst, ScrPromise *src) { + ScrDyn *payload = scr_promise_payload_is_dyn(src) ? (ScrDyn *)scr_promise_payload_ref(src) + : scr_dyn_retain(scr_dyn_undefined()); + ScrDyn *copy = scr_node_island_copy_arrays(payload); + scr_dyn_release(payload); + if (copy == NULL) { + scr_promise_reject_pending(dst); + return; + } + scr_promise_fulfill_ref(dst, copy, scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); +} + +/* `await` of an island promise whose declared result holds arrays: the + * fulfillment value's island arrays copied (inside the settlement, so no + * reaction turn is added), rejections as they are. +1 promise. */ +ScrPromise *scr_node_island_await_copy(ScrDyn *value) { + ScrPromise *src = scr_ni_await(value); + ScrPromise *dst = scr_promise_new(); + scr_promise_mark_handled(src); + scr_promise_race_add(dst, src, nh_copy_fulfilled); + scr_promise_release(src); + return dst; +} + uint32_t scr_ni_live_handles(void) { const ScrNodeBridge *b = scr_ni_bridge_loaded(); if (b == NULL) return 0; diff --git a/packages/runtime/src/scr_node_island_host.cjs b/packages/runtime/src/scr_node_island_host.cjs index 7c9e9487f..fdaaa3ade 100644 --- a/packages/runtime/src/scr_node_island_host.cjs +++ b/packages/runtime/src/scr_node_island_host.cjs @@ -30,6 +30,63 @@ const ERROR_KINDS = [ [URIError, 7], ]; +// structuredClone of native values (NI-5). A native record, class +// instance or object is an object with interceptors in V8, which V8's +// serializer cannot clone (DataCloneError). Node clones such values as the +// plain data they are, so when a clone fails on a value holding native +// values, it clones a plain copy instead: native values and the plain +// objects and arrays around them are copied (own enumerable string keys, +// shared references and cycles kept), everything else is cloned as is. +const nodeStructuredClone = globalThis.structuredClone; +function plainCopy(value, memo) { + if (value === null || typeof value !== "object") return value; + if (memo.has(value)) return memo.get(value); + const native = nativeHost.isExport(value); + if (Array.isArray(value)) { + const out = []; + memo.set(value, out); + for (let i = 0; i < value.length; i++) out[i] = plainCopy(value[i], memo); + return out; + } + const proto = Object.getPrototypeOf(value); + if (!native && proto !== Object.prototype && proto !== null) return value; + const out = {}; + memo.set(value, out); + for (const key of Object.keys(value)) out[key] = plainCopy(value[key], memo); + return out; +} +function holdsNative(value, seen) { + if (value === null || typeof value !== "object" || seen.has(value)) return false; + seen.add(value); + if (nativeHost.isExport(value)) return true; + const proto = Object.getPrototypeOf(value); + if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return false; + for (const key of Object.keys(value)) if (holdsNative(value[key], seen)) return true; + return false; +} +if (typeof nodeStructuredClone === "function") { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "structuredClone"); + const structuredClone = { + structuredClone(value, options = undefined) { + try { + return Reflect.apply(nodeStructuredClone, globalThis, arguments); + } catch (error) { + if (!holdsNative(value, new Set())) throw error; + return Reflect.apply(nodeStructuredClone, globalThis, [ + plainCopy(value, new Map()), + options, + ]); + } + }, + }.structuredClone; + Object.defineProperty(globalThis, "structuredClone", { + configurable: descriptor === undefined || descriptor.configurable, + enumerable: descriptor !== undefined && descriptor.enumerable, + writable: true, + value: structuredClone, + }); +} + const requires = new Map(); function exportedFunction(file, specifier, name) { diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index 78d12611c..9b34349ad 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -37,6 +37,63 @@ static const char scr_node_island_host_js[] = " [URIError, 7],\012" "];\012" "\012" + "// structuredClone of native values (NI-5). A native record, class\012" + "// instance or object is an object with interceptors in V8, which V8's\012" + "// serializer cannot clone (DataCloneError). Node clones such values as the\012" + "// plain data they are, so when a clone fails on a value holding native\012" + "// values, it clones a plain copy instead: native values and the plain\012" + "// objects and arrays around them are copied (own enumerable string keys,\012" + "// shared references and cycles kept), everything else is cloned as is.\012" + "const nodeStructuredClone = globalThis.structuredClone;\012" + "function plainCopy(value, memo) {\012" + " if (value === null || typeof value !== \"object\") return value;\012" + " if (memo.has(value)) return memo.get(value);\012" + " const native = nativeHost.isExport(value);\012" + " if (Array.isArray(value)) {\012" + " const out = [];\012" + " memo.set(value, out);\012" + " for (let i = 0; i < value.length; i++) out[i] = plainCopy(value[i], memo);\012" + " return out;\012" + " }\012" + " const proto = Object.getPrototypeOf(value);\012" + " if (!native && proto !== Object.prototype && proto !== null) return value;\012" + " const out = {};\012" + " memo.set(value, out);\012" + " for (const key of Object.keys(value)) out[key] = plainCopy(value[key], memo);\012" + " return out;\012" + "}\012" + "function holdsNative(value, seen) {\012" + " if (value === null || typeof value !== \"object\" || seen.has(value)) return false;\012" + " seen.add(value);\012" + " if (nativeHost.isExport(value)) return true;\012" + " const proto = Object.getPrototypeOf(value);\012" + " if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return false;\012" + " for (const key of Object.keys(value)) if (holdsNative(value[key], seen)) return true;\012" + " return false;\012" + "}\012" + "if (typeof nodeStructuredClone === \"function\") {\012" + " const descriptor = Object.getOwnPropertyDescriptor(globalThis, \"structuredClone\");\012" + " const structuredClone = {\012" + " structuredClone(value, options = undefined) {\012" + " try {\012" + " return Reflect.apply(nodeStructuredClone, globalThis, arguments);\012" + " } catch (error) {\012" + " if (!holdsNative(value, new Set())) throw error;\012" + " return Reflect.apply(nodeStructuredClone, globalThis, [\012" + " plainCopy(value, new Map()),\012" + " options,\012" + " ]);\012" + " }\012" + " },\012" + " }.structuredClone;\012" + " Object.defineProperty(globalThis, \"structuredClone\", {\012" + " configurable: descriptor === undefined || descriptor.configurable,\012" + " enumerable: descriptor !== undefined && descriptor.enumerable,\012" + " writable: true,\012" + " value: structuredClone,\012" + " });\012" + "}\012" + "\012" "const requires = new Map();\012" "\012" "function exportedFunction(file, specifier, name) {\012" diff --git a/tests/corpus/6837-node-island-clone-and-arrays/main.ts b/tests/corpus/6837-node-island-clone-and-arrays/main.ts new file mode 100644 index 000000000..b029d1f02 --- /dev/null +++ b/tests/corpus/6837-node-island-clone-and-arrays/main.ts @@ -0,0 +1,35 @@ +// @requires-libnode +// structuredClone in a Node island of native objects passed in clones +// their data, as under Node; arrays of plain values the island returns, +// directly or through a promise, arrive as arrays. +import { cloneClaims, cloneFails, cloneNested, listFiles, matrix, names } from "data-pkg"; + +interface Claims { + sub: string; + aud: string; + exp: number; +} + +class Session { + user = "ada"; + level = 2; +} + +async function main(): Promise { + const claims: Claims = { sub: "user-1", aud: "api", exp: 1700000000 }; + console.log(cloneClaims(claims)); + console.log("native unchanged", claims.sub); + console.log(cloneNested({ list: [1, 2], session: new Session() })); + console.log(cloneNested([claims, claims])); + const tags: string[] = ["a", "b"]; + console.log(cloneNested({ tags, claims }), cloneNested(tags)); + console.log(cloneFails()); + const files = await listFiles("/tmp"); + for (const f of files) console.log("file", f); + console.log("count", files.length, files.join(",")); + console.log("names", names().join("|"), names().length); + const m = await matrix(); + console.log("matrix", m.length, m[0].join("+"), m[1][0]); +} + +main(); diff --git a/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.d.ts b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.d.ts new file mode 100644 index 000000000..49f2895b0 --- /dev/null +++ b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.d.ts @@ -0,0 +1,6 @@ +export declare function cloneClaims(payload: object): string; +export declare function cloneNested(value: unknown): string; +export declare function cloneFails(): string; +export declare function listFiles(dir: string): Promise; +export declare function names(): string[]; +export declare function matrix(): Promise; diff --git a/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.js b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.js new file mode 100644 index 000000000..ccfa513c8 --- /dev/null +++ b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/index.js @@ -0,0 +1,29 @@ +"use strict"; +function cloneClaims(payload) { + const copy = structuredClone(payload); + copy.sub = "changed"; + return `${JSON.stringify(copy)} same=${copy === payload} proto=${Object.getPrototypeOf(copy) === Object.prototype}`; +} +function cloneNested(value) { + const copy = structuredClone({ value, when: new Date(0) }); + return `${JSON.stringify(copy.value)} date=${copy.when instanceof Date}`; +} +function cloneFails() { + try { + structuredClone({ f() {} }); + return "cloned"; + } catch (e) { + return `${e.name}`; + } +} +async function listFiles(dir) { + await null; + return [`${dir}/a.txt`, `${dir}/b.txt`]; +} +function names() { + return ["x", "y", "z"]; +} +async function matrix() { + return [[1, 2], [3]]; +} +module.exports = { cloneClaims, cloneNested, cloneFails, listFiles, names, matrix }; diff --git a/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/package.json b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/package.json new file mode 100644 index 000000000..786239119 --- /dev/null +++ b/tests/corpus/6837-node-island-clone-and-arrays/node_modules/data-pkg/package.json @@ -0,0 +1 @@ +{ "name": "data-pkg", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/corpus/6837-node-island-clone-and-arrays/package.json b/tests/corpus/6837-node-island-clone-and-arrays/package.json new file mode 100644 index 000000000..495e2cd73 --- /dev/null +++ b/tests/corpus/6837-node-island-clone-and-arrays/package.json @@ -0,0 +1 @@ +{ "name": "island-clone-and-arrays", "private": true, "type": "module", "scriptc": { "packages": { "data-pkg": "node" } } } diff --git a/tests/harness/corpus-costs/6837-node-island-clone-and-arrays/main.ts.json b/tests/harness/corpus-costs/6837-node-island-clone-and-arrays/main.ts.json new file mode 100644 index 000000000..16841f64d --- /dev/null +++ b/tests/harness/corpus-costs/6837-node-island-clone-and-arrays/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2000, "san": 30000 }