From 291d9b42e455b509e38a46a83f6df289d319efd5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 30 Oct 2026 12:36:08 -0500 Subject: [PATCH] Resolve shared island modules by real path The island loader hooks keyed each shared program module by the file URL of its path in the extracted tree, under the island cache directory. Node resolves an import to the module's real path, so with a cache reached through a symbolic link the two URLs differ. That is the default on macOS, where the temporary directory is under /var, which is /private/var. The import then loaded the module's placeholder, which exports nothing, and linking failed: "The requested module './state.ts' does not provide an export named 'bump'". Each shared module is now keyed by its real path as well. node-island.test.ts runs the shared-module program a second time through a symbolic link to its cache directory, which reproduces the failure on Linux. --- packages/runtime/src/scr_node_island_hooks.cjs | 13 ++++++++++++- packages/runtime/src/scr_node_island_host.h | 13 ++++++++++++- tests/harness/node-island.test.ts | 16 +++++++++++----- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/runtime/src/scr_node_island_hooks.cjs b/packages/runtime/src/scr_node_island_hooks.cjs index b587132cf..568672b2f 100644 --- a/packages/runtime/src/scr_node_island_hooks.cjs +++ b/packages/runtime/src/scr_node_island_hooks.cjs @@ -30,8 +30,19 @@ function scriptcSharedModules(nativeHost) { const table = JSON.parse(nativeHost.sharedTable()); const none = { entry: (path, load) => load(null), push() {} }; if (table.length === 0) return none; + const { realpathSync } = require("node:fs"); + // Node resolves an import to the module's real path, so a module of a + // tree reached through a symbolic link (a cache directory under macOS's + // /var, which is /private/var) is keyed by its real path as well. const byUrl = new Map(); - table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id)); + table.forEach((m, id) => { + byUrl.set(pathToFileURL(m.path).href, id); + try { + byUrl.set(pathToFileURL(realpathSync(m.path)).href, id); + } catch { + // A module that is not on disk keeps only its own path. + } + }); const TDZ = Symbol("scriptc.tdz"); const setters = new Map(); // `${mod}:${slot}` -> [(value) => void] const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] } diff --git a/packages/runtime/src/scr_node_island_host.h b/packages/runtime/src/scr_node_island_host.h index ad73b644b..849262a1e 100644 --- a/packages/runtime/src/scr_node_island_host.h +++ b/packages/runtime/src/scr_node_island_host.h @@ -769,8 +769,19 @@ static const char scr_node_island_host_js[] = " const table = JSON.parse(nativeHost.sharedTable());\012" " const none = { entry: (path, load) => load(null), push() {} };\012" " if (table.length === 0) return none;\012" + " const { realpathSync } = require(\"node:fs\");\012" + " // Node resolves an import to the module's real path, so a module of a\012" + " // tree reached through a symbolic link (a cache directory under macOS's\012" + " // /var, which is /private/var) is keyed by its real path as well.\012" " const byUrl = new Map();\012" - " table.forEach((m, id) => byUrl.set(pathToFileURL(m.path).href, id));\012" + " table.forEach((m, id) => {\012" + " byUrl.set(pathToFileURL(m.path).href, id);\012" + " try {\012" + " byUrl.set(pathToFileURL(realpathSync(m.path)).href, id);\012" + " } catch {\012" + " // A module that is not on disk keeps only its own path.\012" + " }\012" + " });\012" " const TDZ = Symbol(\"scriptc.tdz\");\012" " const setters = new Map(); // `${mod}:${slot}` -> [(value) => void]\012" " const entries = new Map(); // entry URL -> { target, late: [[mod, slot]] }\012" diff --git a/tests/harness/node-island.test.ts b/tests/harness/node-island.test.ts index 18f765354..432246491 100644 --- a/tests/harness/node-island.test.ts +++ b/tests/harness/node-island.test.ts @@ -18,6 +18,7 @@ import { realpathSync, rmSync, statSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -544,13 +545,18 @@ export function patchAndBump(): number { // Node strips types only outside node_modules, and the shared island // cache of these tests lives below the repository's node_modules. const islandCache = mkdtempSync(join(tmpdir(), "scriptc-island-cache-")); + // A cache reached through a symbolic link (macOS's temporary directory + // is under /var, which is /private/var): Node resolves the island's + // import to the shared module's real path. + const linkedCache = `${islandCache}-link`; + symlinkSync(islandCache, linkedCache); try { - const out = await run(join(dir, "main"), [], { - ...process.env, - XDG_CACHE_HOME: islandCache, - }); - expect(out).toEqual({ stdout: "2 2\n", stderr: "", code: 0 }); + for (const cache of [islandCache, linkedCache]) { + const out = await run(join(dir, "main"), [], { ...process.env, XDG_CACHE_HOME: cache }); + expect(out).toEqual({ stdout: "2 2\n", stderr: "", code: 0 }); + } } finally { + rmSync(linkedCache, { force: true }); rmSync(islandCache, { recursive: true, force: true }); } }, 300_000);