fix(chord): nodeLoader registers every facet root before importing any

A facet listed before another facet whose directory it imports from loaded a second copy of that module; register
all roots first so every facet of one load shares one copy, whatever the order.
This commit is contained in:
Mario Zechner 2026-10-12 23:37:22 +00:00
parent d6370b72d0
commit 7d75a0e3eb
3 changed files with 34 additions and 2 deletions

View file

@ -4,6 +4,7 @@
### Fixed
- `nodeLoader` registers every facet's directory before importing any, so a facet that imports a file of a facet listed after it gets the same module copy as that facet; before, the load order could produce duplicates.
- A link that is already closed when a host accepts or routes to it is no longer kept as a connection: before, a route that returned a dead link (a child process that exited before its link was wrapped) made every later call to that host name fail with `unavailable` until restart, and leaked the connection's ping timer.
- Fixed `processLock()` failing with `listen EINVAL` on macOS and the BSDs when the temporary directory is too deep for a socket path (sun_path holds 104 bytes); the lock socket then lives in a private per-user directory, `/tmp/chord2-<uid>`

View file

@ -45,11 +45,15 @@ export const nodeLoader: FacetLoader = async (modules) => {
hook();
const version = String(++generation);
const facets: Facet[] = [];
for (const module of modules) {
const urls = modules.map((module) => {
const url = toUrl(module);
url.search = "";
return url;
});
// Every root first: a facet importing another facet's directory gets this load's copy, whatever the order.
for (const url of urls) roots.add(new URL(".", url).href);
for (const url of urls) {
const name = url.href;
roots.add(new URL(".", url).href);
url.searchParams.set(PARAM, version);
// Owner-approved exception to the no-dynamic-import rule; see TODO(loader) above.
const loaded = (await import(url.href)) as { default?: FacetDefinition };

View file

@ -8,6 +8,8 @@ import { nodeLoader } from "../../src/loader.ts";
// Plugin modules live under the package so bare imports (typebox) resolve; removed afterwards.
const dir = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}/`, import.meta.url));
// Separate from `dir`, which becomes a reload root once its facet loads.
const plugins = fileURLToPath(new URL(`../.tmp-plugins-${process.pid}-order/`, import.meta.url));
const chord = new URL("../../src/index.ts", import.meta.url).href;
// The test's own copy of the plugin's contract: binding is by description, not object identity.
@ -49,6 +51,31 @@ try {
assert.deepEqual(report.changed, ["t.hello"]);
assert.equal(await handle.greet("x"), "v2 x");
await host.close();
// A facet listed first that imports a later facet's directory shares that facet's copy of the module.
const Shared = local("t.shared", {
version: 1,
input: Type.Object({}),
members: { same: method(Type.Null(), Type.Boolean()) },
});
mkdirSync(`${plugins}a`, { recursive: true });
mkdirSync(`${plugins}b`, { recursive: true });
writeFileSync(`${plugins}b/state.ts`, "export const state = {};\n");
writeFileSync(
`${plugins}b/facet.ts`,
`import { defineFacet } from ${JSON.stringify(chord)};\nimport { state } from "./state.ts";\nexport const own = state;\nexport default defineFacet(() => {});\n`,
);
writeFileSync(
`${plugins}a/facet.ts`,
`import { Type } from "typebox";\nimport { defineFacet, local, method } from ${JSON.stringify(chord)};\nimport { state } from "../b/state.ts";\n` +
`const Shared = local("t.shared", { version: 1, input: Type.Object({}), members: { same: method(Type.Null(), Type.Boolean()) } });\n` +
`export default defineFacet((host) => host.serve(Shared, () => ({ same: async () => (await import(new URL("../b/facet.ts", import.meta.url).href)).own === state })));\n`,
);
const ordered = createHost({ loader: nodeLoader, onError: () => {} });
await ordered.load([`${plugins}a/facet.ts`, `${plugins}b/facet.ts`]);
assert.equal(await ordered.get(Shared, {}).same(null), true);
await ordered.close();
} finally {
rmSync(dir, { recursive: true, force: true });
rmSync(plugins, { recursive: true, force: true });
}