Files
pnpm/engine/runtime/system-node-version/src/index.ts
Zoltan Kochan 5dc8be8a42 fix(graph-hasher): resolve GVS engine per-snapshot for runtime-pinned deps (#11693)
Closes #11690.

A dependency that declares `engines.runtime` in its manifest carries the desugared `dependencies.node: 'runtime:<version>'` pin in the lockfile, and pnpm's bin linker spawns that dep's lifecycle scripts through the pinned Node downloaded into `<pkgDir>/node_modules/node/`. The GVS hash and the side-effects-cache key prefix were still anchored to the install-wide runtime — so the pinning snapshot's slot encoded the wrong Node major, and a reinstall on the same host could read the cached side-effects under a key whose `<platform>;<arch>;node<major>` triple disagreed with the Node the build actually ran on.

Per-snapshot resolution now matches what `bins/linker` already does on a per-package basis: a snapshot's own pin wins; the install-wide value (from #11689's `findRuntimeNodeVersion`) is the fallback.

### TypeScript

- `deps/graph-hasher/src/index.ts:72-77` — adds `readSnapshotRuntimePin(children)`: pulls the bare Node version from a graph node's `children.node` entry when that points at a `node@runtime:<version>` snapshot. Factors out a small `extractRuntimeNodeVersion(snapshotKey)` parser shared with `findRuntimeNodeVersion`.
- `deps/graph-hasher/src/index.ts:115-116,245-246` — `calcDepState` and `calcGraphNodeHash` consult `readSnapshotRuntimePin(graph[depPath].children)` first and only fall back to the install-wide `nodeVersion` parameter when the snapshot doesn't pin its own Node. No caller changes required — install-wide fallback continues to be computed via `findRuntimeNodeVersion(Object.keys(graph))` at each call site.
- **Refactor (separate commit):** `findRuntimeNodeVersion` moved from `@pnpm/engine.runtime.system-node-version` to `@pnpm/deps.graph-hasher` (along with the new `readSnapshotRuntimePin`). `system-node-version` is about probing the *host* Node — `getSystemNodeVersion`, `engineName`. The lockfile-shape parsers fit better next to the package that actually composes the engine string. Every caller already depended on graph-hasher, so no new deps; six packages drop the now-unused dependency on `system-node-version`.

### Pacquet

- `pacquet/crates/package-manager/src/install_frozen_lockfile.rs:1309-1345` — new `find_own_runtime_node_major(snapshot)` reads a snapshot's `dependencies` for a `node` entry with `Prefix::Runtime`, returning the bare major.
- `pacquet/crates/package-manager/src/virtual_store_layout.rs:178-205` — `VirtualStoreLayout::new` resolves engine per-snapshot inside the hash loop via `engine_name(own_major, None, None)` when the snapshot pins, otherwise inherits the install-wide `engine` argument.

### Migration

Snapshots of dependencies that declare their own `engines.runtime` re-hash under that dep's pinned Node instead of the install-wide value. Old slots become prune-eligible on next install.
2026-05-17 13:25:05 +02:00

56 lines
2.4 KiB
TypeScript

import { detectIfCurrentPkgIsExecutable } from '@pnpm/cli.meta'
import * as execa from 'execa'
import mem from 'memoize'
export function getSystemNodeVersionNonCached (): string | undefined {
if (detectIfCurrentPkgIsExecutable()) {
try {
return execa.sync('node', ['--version']).stdout?.toString()
} catch {
// Node.js is not installed on the system
return undefined
}
}
return process.version
}
export const getSystemNodeVersion = mem(getSystemNodeVersionNonCached)
/**
* The `<platform>;<arch>;node<major>` string used as the side-effects
* cache-key prefix and the engine portion of the global-virtual-store
* hash. Identifies the runtime environment that built (or will build)
* a package's lifecycle scripts — so two installs that materialize the
* same package on the same host produce the same key.
*
* The Node version is resolved in this order:
*
* 1. `nodeVersion` argument when provided. Callers use this to thread
* a project-pinned runtime (`engines.runtime` / `devEngines.runtime`)
* through to the hash — see `findRuntimeNodeVersion` /
* `readSnapshotRuntimePin` in `@pnpm/deps.path` for the helpers
* that extract the value from a lockfile or graph node.
* 2. {@link getSystemNodeVersion} — the `node` on the user's `PATH`,
* or `process.version` when not SEA-bundled.
* 3. `process.version` as a last-resort fallback when the host has
* no `node` on `PATH` (rare: SEA pnpm with no separately-installed
* Node). Scripts cannot run in that scenario regardless, so the
* cache key is effectively unused — the fallback exists only to
* keep the value deterministic.
*
* Anchoring to a project-pinned or script-runner Node — not to pnpm's
* own `process.version` — matters most when pnpm ships via the
* `@pnpm/exe` SEA bundle, which has an embedded Node distinct from
* the one that actually runs lifecycle scripts. Without the override,
* a project with `devEngines.runtime: node@22` would still hash under
* the SEA-runner's Node major, splitting the cache across two pnpm
* installations on the same machine even though both run scripts on
* the same pinned Node.
*/
export function engineName (nodeVersion?: string): string {
const version = nodeVersion ?? getSystemNodeVersion() ?? process.version
const stripped = version.startsWith('v') ? version.slice(1) : version
const major = stripped.split('.')[0]
return `${process.platform};${process.arch};node${major}`
}