Skip to content

GVS engine field should resolve per-snapshot for deps with engines.runtime pins #11690

@zkochan

Description

@zkochan

Context

PR #11689 anchors engineName() to a project-wide pinned Node version (engines.runtime / devEngines.runtime on the root manifest) instead of pnpm's own process.version, by computing the version once per install via findRuntimeNodeVersion(Object.keys(lockfile.packages ?? {})) and threading it through every site that calls calcDepState / calcGraphNodeHash / iterateHashedGraphNodes.

That change closes the cache-key divergence between @pnpm/exe (SEA-bundled Node) and the shell node that actually runs lifecycle scripts for the common case. It does not close the equivalent divergence for a different case the resolver already handles, which is the subject of this issue.

The gap

installing/deps-resolver/src/resolveDependencies.ts:1477-1479 runs the same engines.runtimedependencies.node desugar against every resolved dependency's manifest, not just the project's:

if (pkg.engines?.runtime != null) {
  convertEnginesRuntimeToDependencies(pkg, 'engines', 'dependencies')
}

So when a third-party package declares engines.runtime: node@22, the resolver injects dependencies.node: 'runtime:22.0.0' into that one package's manifest, the npm-resolver downstream emits a node@runtime:22.0.0 snapshot, and the snapshot becomes a real child of the pinning package in the lockfile. Pnpm's bins/linker/src/index.ts:229-237 then routes lifecycle-script spawns for that package through the pinned Node binary at <pkgDir>/node_modules/node/bin/node (or node.exe).

The deps half of the hash already reflects this. calcDepGraphHash walks the pinning package's children, finds node@runtime:22.0.0, mixes its id into the inner {id, deps} object, and any snapshot that transitively reaches the pinning package picks up the same contribution. Pacquet's port (virtual_store_layout::collect_children + lockfile_to_dep_graph) does the equivalent walk over snapshot.dependencies. Symmetric on both sides, and already captured.

The engine half doesn't. calcGraphNodeHash uses a single install-wide engine string for every snapshot's outer {engine, deps} payload. So:

  • Pinning package: scripts actually run on its pinned Node (node22 above); the engine field reads whatever the install-wide default is — system node24 in the common case, or whatever the project pinned. Hash misrepresents the script-runner Node.
  • Non-pinning sibling: scripts run on system node24; engine field reads node24. Correct.

The pinning package's slot still has a distinct GVS path from non-pinning siblings — different deps means different deps hash means different outer hash — so installs don't collide on disk. What's wrong is the side-effects-cache key prefix (<platform>;<arch>;node<major>;deps=…). It encodes the system Node major even though the cached side-effects came from a build that ran on the pinned Node. A reinstall on the same system reads the cached artifact via the system-Node-anchored key and reuses build output that was produced on a different Node. Stale side-effects cache, silently.

This is a pnpm bug today; PR #11689 is symmetric to pnpm so pacquet doesn't widen it, but pacquet also doesn't close it.

What the fix needs to look like

The engine resolution moves from "once per install" to "once per snapshot". Sketch:

function snapshotEngine(snapshot: LockfilePackageSnapshot, projectPin: string | undefined): string {
  // The desugar at resolveDependencies.ts:1477-1479 writes
  // `dependencies.node: 'runtime:<version>'` into the pinning
  // snapshot. Read it back the same way `findRuntimeNodeVersion`
  // reads the project's importer pin: any child with `node` as
  // its alias and a value starting with `runtime:`.
  const ownPin = readOwnRuntimePin(snapshot)
  return engineName(ownPin ?? projectPin)
}

Threaded through calcGraphNodeHash and calcDepState as a per-snapshot value instead of the current ctx-level nodeVersion. Same change on the pacquet side: each snapshot consults its own dependencies map for a node entry with a runtime: prefix before falling back to find_runtime_node_major(snapshots).

The shape mirrors the install-time policy that's already there — bins/linker reads the dep's own engines.runtime per-package — so the engine string in the hash would just be tracking what the bin linker already does on a per-package basis.

Scope

  • deps/graph-hasher: replace nodeVersion?: string with a per-snapshot lookup helper, or take a nodeVersionFor: (snapshot) => string | undefined callback.
  • deps/graph-builder, installing/deps-resolver, installing/deps-restorer (both index.ts and linkHoistedModules.ts), installing/deps-installer/install/link.ts, building/during-install, building/after-install: switch each calcGraphNodeHash / calcDepState call from forwarding the install-wide nodeVersion to consulting the per-snapshot helper.
  • engine.runtime.system-node-version: extend findRuntimeNodeVersion with a sibling that reads from a single snapshot's dependencies map rather than from a flat key list.
  • Pacquet: mirror the per-snapshot lookup at the calc_graph_node_hash call site in package-manager/src/virtual_store_layout.rs::new. The existing find_runtime_node_major(snapshots) helper becomes the fallback case; the new helper reads each snapshot's dependencies for a node entry with Prefix::Runtime and prefers that.

The unit tests need a fixture where two siblings declare different engines.runtime pins to surface the divergence; the existing parity tests in pacquet/crates/cli/tests/pnpm_compatibility.rs would extend with a third case along those lines.

Why not in #11689

PR #11689 already handles the common case (project-level pin) and matches pnpm's current behaviour for the dep-level pin. Bundling the per-snapshot refactor would roughly double the diff and the test surface (need cross-pinning fixtures on both sides) while only addressing a corner of the cache-key contract that pnpm currently gets wrong in the same way. Cleaner to land #11689 first and address this incrementally.


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions