Skip to content

cache: a FAILED install is saved and restored as a hit — broken-green for every JS consumer #119

Description

@nplusonedev

composeRestoreOr saves after any onMiss that succeeds as an Effect:

// packages/runtime-cf/src/cache-restore-or.ts
: opts.onMiss().pipe(
    Effect.tap(() => Effect.ignore(save(opts))),
    Effect.asVoid,
  ),

Its docstring says "the onMiss work already succeeded". That is true in the Effect sense and misleading in the exit-code sense, because ExecResult's own contract says:

A non-zero exitCode is a normal result (a failing test), surfaced to the run — not an Effect failure.

So an install that fails its own way — npm ci exiting non-zero halfway, a network blip mid-pnpm install — returns a successful Effect. save runs. Whatever partial node_modules exists on disk is packed under the lockfile-hash key.

The next run restores it, treats it as a hit, and skips the install. That is a broken dependency tree presented as a cache hit, and the run proceeds green until something fails much later for an unrelated-looking reason.

Severity by tool

  • npm / pnpm — acute. A partial node_modules with the install skipped is exactly the failure the reinstallOnHit discussion in fix(cache): a Rust run stops re-downloading its whole registry every time #118 argued against. Live on main today for every JS consumer.
  • uv — same shape, untested by me.
  • cargo — mild. A partial registry restored as a hit still works: cargo build is not --offline and fetches what is missing. Slow, not wrong.

Note #118 newly makes cargo reachable by this path at all — before it, the cargo save always failed because .cargo-registry did not exist to tar. That does not change the severity ranking above.

Why it has stayed invisible

The cache fake in packages/core/src/fakes/misc-fakes.ts is always-miss and save is a no-op, so no unit test can reach the restore-a-poisoned-tree path. It only shows up as an intermittent, confusing red on a real consumer after a flaky install.

Options

  1. Gate the save on the install's exit code — onMiss would have to yield it, which changes restoreOr's signature.
  2. Have installCached pass an onMiss that fails the Effect on a non-zero exit, so Effect.tap never fires. Smaller blast radius; keeps restoreOr as-is.
  3. Make a hit re-run the install for tools whose install is idempotent — the right answer for cargo, the wrong one for npm ci on its own.

I lean 2. Worth deciding deliberately rather than by whichever is quickest.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions