From e356f4988fa6d32ca7f7fd45475bc4ed6bb86e0a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 4 Nov 2026 08:59:04 -0500 Subject: [PATCH] Skip a binding for the other libc when resolving from package.json npm resolves from full packuments (build-ideal-tree's fullMetadata), which carry each version's libc, so `npm install --os linux --cpu x64 --libc glibc` installs a -gnu binding and skips its -musl twin. The resolver reads the abbreviated packument, which drops libc, so both passed the platform check and installed. Registry.libc reads it from the version document the lockfile fields already come from (one shared fetch), and the install's platform check uses it for every resolved node that names an os or cpu and passes them. A lockfile's entries carry no libc, and npm ci installs both from them, so lockfile nodes are left as they are. The lockfile bytes do not change. --- packages/kernel/src/kernel/npm/install.ts | 14 +++++- packages/kernel/src/kernel/npm/registry.ts | 50 +++++++++++++--------- packages/kernel/tests/install.spec.ts | 36 ++++++++++++++++ packages/kernel/tests/npm-registry.ts | 4 ++ 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/kernel/src/kernel/npm/install.ts b/packages/kernel/src/kernel/npm/install.ts index 0ceb00c..d921910 100644 --- a/packages/kernel/src/kernel/npm/install.ts +++ b/packages/kernel/src/kernel/npm/install.ts @@ -16,7 +16,7 @@ import { fetchLockMeta, hiddenLockfile, jsonFormat, normalizeRoot, packageLock, import { createRegistry, registryTarball, type LockMeta, type Registry } from "./registry"; import { resolve } from "./resolve"; import { addDeps, packageJsonText, parseRequested, rmDeps, savedSpec, setSaved } from "./save"; -import { parseSpec } from "./spec"; +import { parseSpec, type Version } from "./spec"; import { fetchPackage, settleStore } from "./store"; import type { PackageFile } from "./unpacked"; import { loadTree, optionalSet, type Node, type Source, type TarballNode, type TarballRef, type Tree } from "./tree"; @@ -219,8 +219,9 @@ export async function install(fs: MemoryFS, request: InstallRequest, changed: () const skip = (node: Node): void => { for (const n of optionalSet(node)) skipped.add(n); }; + const libc = await resolvedLibc(nodes, registry); for (const node of nodes) { - if (node.inert || node.inBundle || platformAllows(node, PLATFORM, ARCH, LIBC)) continue; + if (node.inert || node.inBundle || platformAllows({ ...node, libc: node.libc ?? libc.get(node) }, PLATFORM, ARCH, LIBC)) continue; if (!node.optional) throw badPlatform(node); skip(node); } @@ -398,6 +399,15 @@ function noPackageJson(path: string): Error { ); } +// A resolved platform package's libc, which its abbreviated packument left out: npm +// resolves from full packuments, so a -musl binding it would skip is skipped here too. +// A lockfile's entries carry no libc, and npm ci installs both bindings from them. +async function resolvedLibc(nodes: readonly Node[], registry: Registry): Promise> { + const unknown = nodes.filter((n) => !n.fromLockfile && !n.inert && !n.inBundle && !n.libc && (n.os || n.cpu) && platformAllows(n, PLATFORM, ARCH, LIBC)); + const found = await Promise.all(unknown.map((n) => registry.libc(n.packageName, n.version as Version))); + return new Map(unknown.flatMap((n, i) => (found[i] ? [[n, found[i]] as const] : []))); +} + // npm-install-checks' checkPlatform, which arborist runs on every non-optional node. function badPlatform(node: Node): Error { const wanted = { os: node.os?.join(",") ?? "any", cpu: node.cpu?.join(",") ?? "any", ...(node.libc && { libc: node.libc.join(",") }) }; diff --git a/packages/kernel/src/kernel/npm/registry.ts b/packages/kernel/src/kernel/npm/registry.ts index bf16779..ddf9dfa 100644 --- a/packages/kernel/src/kernel/npm/registry.ts +++ b/packages/kernel/src/kernel/npm/registry.ts @@ -46,6 +46,9 @@ export interface Registry { manifest(spec: RegistrySpec): Promise; /** `GET //`, stored without expiry: a published version never changes. */ lockMeta(name: string, version: Version): Promise; + /** The version's `libc`, from the same document: the abbreviated packument drops it, + * and npm reads it from the full packument (build-ideal-tree's fullMetadata). */ + libc(name: string, version: Version): Promise; readonly counts: MetadataCounts; /** A stale packument that answered because the registry could not be reached. */ readonly warnings: readonly string[]; @@ -90,7 +93,7 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { const counts = { cached: 0, fetched: 0 }; const warnings: string[] = []; const packuments = new Map>(); - const lockMetas = new Map>(); + const versionDocs = new Map>(); // `stale` is the stored copy past its max-age, which answers if the registry cannot. const fetchPackument = async (name: string, revalidate: boolean, stale?: StoredMeta): Promise => { @@ -134,6 +137,27 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { return packuments.get(name)!; }; + const versionDoc = (name: string, version: Version): Promise => { + const key = `${name}@${version}`; + let doc = versionDocs.get(key); + if (!doc) { + doc = (async () => { + const url = `${packumentUrl(name)}/${version}`; + const stored = await storedMeta(url, JSON_ACCEPT); + if (stored !== undefined) { + counts.cached++; + return stored.json; + } + const res = await fetchMeta(url, JSON_ACCEPT, false); + counts.fetched++; + if (!res.ok) throw fetchFailed(url, res); + return res.json; + })(); + versionDocs.set(key, doc); + } + return doc; + }; + const pick = (loaded: Loaded, spec: RegistrySpec) => pickManifest(loaded.packument, spec, { nodeVersion }); return { @@ -154,25 +178,11 @@ export function createRegistry(nodeVersion: string = NODE_VERSION): Registry { if (!mani) throw new RegistryError("ETARGET", `No matching version found for ${spec.name}@${spec.raw}.`); return mani; }, - lockMeta(name, version) { - const key = `${name}@${version}`; - let meta = lockMetas.get(key); - if (!meta) { - meta = (async () => { - const url = `${packumentUrl(name)}/${version}`; - const stored = await storedMeta(url, JSON_ACCEPT); - if (stored !== undefined) { - counts.cached++; - return parseLockMeta(stored.json); - } - const res = await fetchMeta(url, JSON_ACCEPT, false); - counts.fetched++; - if (!res.ok) throw fetchFailed(url, res); - return parseLockMeta(res.json); - })(); - lockMetas.set(key, meta); - } - return meta; + lockMeta: async (name, version) => parseLockMeta(await versionDoc(name, version)), + async libc(name, version) { + const doc = await versionDoc(name, version); + const libc = isRecord(doc) ? doc.libc : undefined; + return Array.isArray(libc) && libc.every((x) => typeof x === "string") ? libc : undefined; }, }; } diff --git a/packages/kernel/tests/install.spec.ts b/packages/kernel/tests/install.spec.ts index 7ac56d3..a1876af 100644 --- a/packages/kernel/tests/install.spec.ts +++ b/packages/kernel/tests/install.spec.ts @@ -245,6 +245,42 @@ test("the runtime is a glibc linux: a -musl binding is skipped and the -gnu one ]); }); +// Resolved from package.json alone, the registry's abbreviated packument has no libc; +// npm resolves from full packuments, so it skips a binding for the other libc. These +// docs drop `cpu` so that libc alone decides; the version documents carry it. +test("resolving from package.json, a -musl binding is skipped by the libc its version document names", async ({ page, context }) => { + const docs = Object.fromEntries((["gnu", "musl"] as const).map((libc) => { + const name = `@napi-rs/lzma-linux-x64-${libc}`; + const { optional: _, cpu: ___, resolved, integrity, ...entry } = LZMA(libc, JSON.parse(LIBC_LOCK).packages[`node_modules/${name}`].integrity); + const full = { name, ...entry, dist: { tarball: resolved, integrity } }; + const { libc: __, ...abbreviated } = full; + return [name, { full, packument: { name, "dist-tags": { latest: "1.5.1" }, versions: { "1.5.1": abbreviated } } }]; + })); + const log: string[] = []; + await context.route("https://registry.npmjs.org/**", async (route) => { + const path = decodeURIComponent(new URL(route.request().url()).pathname.slice(1)); + if (path.includes("/-/")) return route.fallback(); + log.push(`/${path}`); + const [scope, name, version] = path.split("/"); + const doc = docs[`${scope}/${name}`]; + const body = version ? doc?.full : doc?.packument; + const cors = { "access-control-allow-origin": "*" }; + if (!body) return route.fulfill({ status: 404, headers: cors, contentType: "application/json", body: '{"error":"Not found"}' }); + return route.fulfill({ headers: cors, contentType: "application/json", body: JSON.stringify(body) }); + }); + const { report, error } = await installFiles(page, { "/app/package.json": JSON.stringify(JSON.parse(LIBC_LOCK).packages[""]) }); + expect(error).toBeNull(); + expect([report!.lockfile, report!.packages, report!.skipped]).toEqual(["written", 1, ["node_modules/@napi-rs/lzma-linux-x64-musl"]]); + expect(log.sort()).toEqual([ + "/@napi-rs/lzma-linux-x64-gnu", "/@napi-rs/lzma-linux-x64-gnu/1.5.1", + "/@napi-rs/lzma-linux-x64-musl", "/@napi-rs/lzma-linux-x64-musl/1.5.1", + ]); + expect(Object.keys((await listModules(page)).files).sort()).toEqual([ + "/app/node_modules/.package-lock.json", + "/app/node_modules/@napi-rs/lzma-linux-x64-gnu/package.json", + ]); +}); + // fsevents 1.2.13 is darwin-only and depends on bindings and nan. The root depends on // bindings too, and on fsevents optionally. The lockfile is what npm 11.7 wrote for // this package.json; `npm ci --os linux --cpu x64` from it skips nan with fsevents, diff --git a/packages/kernel/tests/npm-registry.ts b/packages/kernel/tests/npm-registry.ts index c263fee..ad9c3e2 100644 --- a/packages/kernel/tests/npm-registry.ts +++ b/packages/kernel/tests/npm-registry.ts @@ -43,6 +43,10 @@ function registryOver(packument: (name: string) => Packument | undefined): TestR if (!doc) throw new RegistryError("E404", `404 Not Found - GET https://registry.npmjs.org/${name.replace("/", "%2f")}/${version}`); return parseLockMeta(doc); }, + async libc(name, version) { + const libc = (packument(name)?.versions.get(version)?.doc as { libc?: unknown } | undefined)?.libc; + return Array.isArray(libc) ? libc : undefined; + }, }; } -- knotmirror 0.0.1