Describe the bug
Two PullModel calls running at the same time — two kit pull processes, or two goroutines in a process embedding the library — are not isolated from each other, even when they pull completely different refs. Local storage is a single shared root, so concurrent pulls collide on the ingest directory and on index.json. The worst outcome is silent: a model that pulled successfully disappears from the shared index and becomes eligible for garbage collection.
There is no cross-process locking anywhere in pkg/ (no flock, no lockfile), so nothing prevents this today.
Root cause: refs do not partition storage
pkg/cmd/pull/pull.go:41-42 builds every repo on one root, constants.StoragePath(configHome), and newLocalRepoForName (pkg/lib/repo/local/registry.go:68-87) opens the oci.Store at that root directly. The ref only selects a per-repo index filename (<base64(repo)>-index.json), never a subdirectory. All refs therefore share one blobs/sha256/ and one ingest/. Because storage is content-addressed, two different refs that share a layer digest — same base model under two tags, a common case — resolve to the same physical paths.
Four concrete failures
-
Ingest file collision on the resumable download path. resumeAndDownloadFile (pkg/lib/repo/local/pull.go:146-147) opens a fixed filename ingest/<digest> with O_CREATE|O_RDWR and no exclusivity. The pulledDigests map at pull.go:77 only dedupes within a single PullModel call. Two concurrent pulls of the same digest interleave writes into one file, and the resume logic (pull.go:159-169) reads the other writer's partial bytes as its own resume offset. After the first pull renames the file into blobs/, the second writer's still-open fd points at the now-live blob and keeps writing into it. The non-seekable downloadFile path is safe here — it uses os.CreateTemp with a random suffix (pull.go:198).
-
cleanupIngestDir deletes other pulls' in-flight files. A completing pull calls cleanupIngestDir (pull.go:118), which removes every file under the shared ingest/ dir (pull.go:248-266) — including another pull's active ingest file. That pull's final os.Rename then fails with ENOENT and its resume state is gone.
-
Lost updates on the shared index.json. PullModel calls l.Store.Tag(ctx, desc, desc.Digest.String()) at pull.go:107. In oras-go v2.6.x, oci.Store.saveIndex rewrites the whole index.json from that instance's in-memory tag map via os.WriteFile. Its mutexes are per-Store-instance and protect nothing across two localRepo instances or two processes. Concurrent pulls of any two refs — including different registries and repositories — race on this one file, and the loser's manifest entry vanishes. Per the comment at pull.go:106, that entry is exactly what keeps the manifest safe from garbage collection, so a successful pull silently becomes GC bait.
-
Non-atomic index writes. Both localIndex.save (pkg/lib/repo/local/repository.go:104) and tagsIndex.save (repository.go:224) write in place with os.WriteFile — truncate, then write. A crash or a full disk mid-write leaves truncated JSON, and a concurrent reader can observe a partial file. Separately, each localRepo loads its index at construction, so two pulls of different tags in the same repo perform an unlocked read-modify-write and the last writer silently drops the other's manifest and tag.
To Reproduce
Cross-repo, hits failure 3 — the two refs share no content at all:
kit pull registry-a.example.com/org/model-a:latest &
kit pull registry-b.example.com/org/model-b:latest &
wait
kit list # one of the two models is missing from the shared index
Same-repo with shared layers, hits 1, 2 and 4 as well:
kit pull registry.example.com/org/model:v1 &
kit pull registry.example.com/org/model:v2 &
wait
Failure 1 needs a registry that supports range requests, since it only affects the resumable path.
Expected behavior
Concurrent pulls either serialize safely or fail loudly. Neither should ever leave storage in a state where a completed pull is missing from an index, an index file is truncated, or a blob contains interleaved bytes from two writers.
Additional context
Found while reviewing serialization in the local repo layer. Filing separately: the existing work is about making in-process serialization correct, whereas this is a distinct set of failure modes across processes and storage layout, with its own design decisions.
Describe the bug
Two
PullModelcalls running at the same time — twokit pullprocesses, or two goroutines in a process embedding the library — are not isolated from each other, even when they pull completely different refs. Local storage is a single shared root, so concurrent pulls collide on the ingest directory and onindex.json. The worst outcome is silent: a model that pulled successfully disappears from the shared index and becomes eligible for garbage collection.There is no cross-process locking anywhere in
pkg/(no flock, no lockfile), so nothing prevents this today.Root cause: refs do not partition storage
pkg/cmd/pull/pull.go:41-42builds every repo on one root,constants.StoragePath(configHome), andnewLocalRepoForName(pkg/lib/repo/local/registry.go:68-87) opens theoci.Storeat that root directly. The ref only selects a per-repo index filename (<base64(repo)>-index.json), never a subdirectory. All refs therefore share oneblobs/sha256/and oneingest/. Because storage is content-addressed, two different refs that share a layer digest — same base model under two tags, a common case — resolve to the same physical paths.Four concrete failures
Ingest file collision on the resumable download path.
resumeAndDownloadFile(pkg/lib/repo/local/pull.go:146-147) opens a fixed filenameingest/<digest>withO_CREATE|O_RDWRand no exclusivity. ThepulledDigestsmap atpull.go:77only dedupes within a singlePullModelcall. Two concurrent pulls of the same digest interleave writes into one file, and the resume logic (pull.go:159-169) reads the other writer's partial bytes as its own resume offset. After the first pull renames the file intoblobs/, the second writer's still-open fd points at the now-live blob and keeps writing into it. The non-seekabledownloadFilepath is safe here — it usesos.CreateTempwith a random suffix (pull.go:198).cleanupIngestDirdeletes other pulls' in-flight files. A completing pull callscleanupIngestDir(pull.go:118), which removes every file under the sharedingest/dir (pull.go:248-266) — including another pull's active ingest file. That pull's finalos.Renamethen fails with ENOENT and its resume state is gone.Lost updates on the shared
index.json.PullModelcallsl.Store.Tag(ctx, desc, desc.Digest.String())atpull.go:107. In oras-go v2.6.x,oci.Store.saveIndexrewrites the wholeindex.jsonfrom that instance's in-memory tag map viaos.WriteFile. Its mutexes are per-Store-instance and protect nothing across twolocalRepoinstances or two processes. Concurrent pulls of any two refs — including different registries and repositories — race on this one file, and the loser's manifest entry vanishes. Per the comment atpull.go:106, that entry is exactly what keeps the manifest safe from garbage collection, so a successful pull silently becomes GC bait.Non-atomic index writes. Both
localIndex.save(pkg/lib/repo/local/repository.go:104) andtagsIndex.save(repository.go:224) write in place withos.WriteFile— truncate, then write. A crash or a full disk mid-write leaves truncated JSON, and a concurrent reader can observe a partial file. Separately, eachlocalRepoloads its index at construction, so two pulls of different tags in the same repo perform an unlocked read-modify-write and the last writer silently drops the other's manifest and tag.To Reproduce
Cross-repo, hits failure 3 — the two refs share no content at all:
Same-repo with shared layers, hits 1, 2 and 4 as well:
Failure 1 needs a registry that supports range requests, since it only affects the resumable path.
Expected behavior
Concurrent pulls either serialize safely or fail loudly. Neither should ever leave storage in a state where a completed pull is missing from an index, an index file is truncated, or a blob contains interleaved bytes from two writers.
Additional context
Found while reviewing serialization in the local repo layer. Filing separately: the existing work is about making in-process serialization correct, whereas this is a distinct set of failure modes across processes and storage layout, with its own design decisions.