Skip to content

imagecache: Re-unpack a layer retired while joining its flight - #1509

Open
igooch wants to merge 1 commit into
agent-substrate:mainfrom
igooch:fix/ensurelayer-retire-flight-join
Open

imagecache: Re-unpack a layer retired while joining its flight#1509
igooch wants to merge 1 commit into
agent-substrate:mainfrom
igooch:fix/ensurelayer-retire-flight-join

Conversation

@igooch

@igooch igooch commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1076

The bug

The reuse/retire interlock was one-sided. retireLayer treats joining an
in-flight call as a veto — its closure never ran, so it knows nothing about the
dir. ensureLayer made the same join and concluded the opposite: Do returned
the other call's nil error, so it returned the layer path as if verified.

When the flight it joined was a retirement, that path had just been renamed
aside and nothing unpacked it back. pull recorded the layer, and its
re-verify reported layer dir vanished during pull (evicted?) — failing a
healthy pull into an RPC retry. A few microseconds wide, hence CI-only.

Correcting the diagnosis in #1076

The issue blames an eviction cutoff postdating ensureLayer's mtime refresh.
That can't fire here: the test uses the default minAge = 2m, so
cutoff = now - 2m while a refreshed dir has mtime ≈ nowretireLayer
always vetoes. That window is real only at minAge ≈ 0.

Evidence

Counting joins that leave no fs dir gives an exact 1:1 match with the failure
(34→34, 149→149, 191→191, 1→1). No failure ever occurred without a join, the
rate tracks the flight-window width, and guarding the join drops it to zero
while the joins continue.

A/B on this branch, same harness, 200 iterations per head start:

head start without fix with fix
100µs 66 0
200µs 182 0
400µs 186 0

The fix

Give ensureLayer the guard retireLayer already has: accept a dir only if we
ran the flight ourselves, or the tree is still on disk once the joined flight
finishes. Otherwise we joined a retirement — re-enter and unpack again, bounded
by maxEnsureLayerFlights.

Checking the dir rather than always re-entering keeps the dedup: unconditional
retry would make N actors pulling one image each spin for their own flight.
No livelock — retireLayer vetoes on the symmetric join.

Unchanged on purpose

pull's re-verify and TestPullReverifyFailsCleanlyOnYankedLayer stay. The
re-verify guards a dir removed outside the flight, which the singleflight
can't see. Fixing the cause didn't require weakening it.

Testing

TestEnsureLayerJoiningRetireFlightRepacksLayer reproduces this
deterministically by occupying the flight as retireLayer does. Fails on
main, passes here.

  • Full package -race: 70 pass, 0 fail. TestConcurrentEnsureImageAndEvict:
    500 consecutive -race runs clean.
  • make verify clean except metrics.sh (needs Docker), proto-fmt.sh (needs
    clang-format), shellcheck.sh — the latter two fail identically on main,
    and this touches no metrics, protos, or shell scripts.

Follow-up (separate issue)

TestConcurrentEnsureImageAndEvict tests less than it appears: over 5,000
iterations it produced 33 candidates, skipped all as fresh, and removed zero
records — the EnsureImage hit beats the evictor ~99.3% of the time, so the
eviction-wins branch is almost never taken.

The reuse/retire interlock was one-sided. retireLayer treats joining a
call already in flight as a veto, because its closure never ran and it
therefore knows nothing about the dir. ensureLayer made the same kind of
join and drew the opposite conclusion: singleflight.Do returned the other
call's nil error, so ensureLayer returned the layer path as if it had
verified it. When the flight it joined was a retirement, that path had
just been renamed aside and nothing unpacked it back.

The pull path then recorded the layer and its final re-verify reported
"layer dir vanished during pull", failing an otherwise healthy pull into
an RPC retry. The window is a few microseconds wide, so it surfaced only
as a rare failure under CI load.

Give ensureLayer the guard retireLayer already has: settle for a dir only
when we ran the flight ourselves, or when the tree is still on disk once
the joined flight finishes. Anything else means we joined a retirement,
so re-enter and unpack the layer again, bounded by maxEnsureLayerFlights.
Checking the dir rather than always re-entering keeps the dedup intact,
so concurrent pulls of one image still collapse onto a single unpack.

The pull path keeps its re-verify: it still guards a dir removed by
something outside the flight, which the singleflight cannot see.
@ahmedtd

Copy link
Copy Markdown
Collaborator

Can you clean up the PR description? It's very difficult to follow.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TestConcurrentEnsureImageAndEvict flakes

2 participants