Skip to content

Custom resolution whose integrity changed is not re-fetched: lockfile updated, node_modules stale #15670

Description

@TrevorBurnham

pnpm version

12.7.0 (11.28.0 behaves correctly)

Reproduction steps

mkdir /tmp/refresh-repro && cd /tmp/refresh-repro

build () {
  rm -rf stage && mkdir -p stage/package
  echo '{"name":"dep-a","version":"1.0.0"}' > stage/package/package.json
  echo "$1" > stage/package/payload.txt
  ( cd stage && COPYFILE_DISABLE=1 tar -czf ../served.tgz package )
}

echo '{"name":"repro","version":"1.0.0","dependencies":{"dep-a":"1.0.0"}}' > package.json
echo 'optimisticRepeatInstall: false' > pnpm-workspace.yaml
cat > .pnpmfile.cjs <<'EOF'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const served = path.join(__dirname, 'served.tgz')
const integrity = () => 'sha512-' + crypto.createHash('sha512').update(fs.readFileSync(served)).digest('base64')

module.exports = {
  resolvers: [{
    canResolve: (wanted) => wanted.alias === 'dep-a',
    resolve: async () => ({
      id: 'dep-a@1.0.0',
      manifest: { name: 'dep-a', version: '1.0.0' },
      resolution: { type: 'custom:repro', integrity: integrity() },
    }),
    shouldRefreshResolution: (depPath, snapshot) => snapshot.resolution.integrity !== integrity(),
  }],
  fetchers: [{
    canFetch: (pkgId, resolution) => resolution.type === 'custom:repro',
    fetch: (cafs, resolution, opts, fetchers) =>
      fetchers.localTarball(cafs, { tarball: 'file:' + served, integrity: integrity() }, opts),
  }],
}
EOF

build V1
pnpm install
cat node_modules/dep-a/payload.txt   # V1

build V2                             # same name@version, new bytes
pnpm install
cat node_modules/dep-a/payload.txt   # V1 on 12.7.0, V2 on 11.28.0
grep integrity pnpm-lock.yaml        # new integrity on both

Describe the Bug

On the second install, shouldRefreshResolution returns true and pnpm writes the new integrity to the lockfile, but it never calls the custom fetcher and leaves the old files in node_modules. It then prints "Already up to date". Because the lockfile now matches the new tarball, later installs (including --frozen-lockfile) see nothing to fix. Only --force or deleting node_modules recovers.

Cause: LockfileResolution::integrity() returns None for Custom resolutions (crates/lockfile/src/resolution.rs). Both checks that decide whether to reuse a virtual-store slot compare integrities through it: current_entry_unchanged in create_virtual_store/snapshot_plan.rs and package_content_changed in create_virtual_store/cache_keys.rs. None == None counts as unchanged, so the slot is kept and not re-imported. pnpm 11's isIntegrityEqual (deps/graph-builder/src/lockfileToDepGraph.ts) reads the integrity field of any resolution, custom ones included.

#15030 fixed the --force path for the same scenario with a tarball resolution. That issue's repro doesn't hit this bug because tarball resolutions expose their integrity.

Expected Behavior

When a custom resolution's integrity changes, pnpm fetches it and re-imports the slot on a plain install, as pnpm 11 does. Built-in verification can keep treating custom integrity as opaque. Only the reuse checks need to compare it.

Related: the hook is skipped silently by default

The repro sets optimisticRepeatInstall: false. With the default (true), the repeat-install fast path in install/run/fast_path.rs returns "Already up to date" before shouldRefreshResolution runs, so the hook cannot fire unless a manifest or the pnpmfile changed. pnpm 11 has the same shortcut but warns: "shouldRefreshResolution hooks were skipped because optimisticRepeatInstall is enabled." pnpm 12 prints no warning.

Node.js version

24.21.0

Operating System

macOS


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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions