Skip to content

rust: temporary and aside names are per process and created exclusively (#482) - #516

Merged
EricAndrechek merged 3 commits into
mainfrom
fix/rust-install-names
Oct 6, 2026
Merged

EricAndrechek merged 3 commits into
mainfrom
fix/rust-install-names

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

A residual concurrent-install race in Rust (#482's follow-up). It ships in 1.1.0. One v1-cache-interop run at 08cfec9 failed 1 of 24 Rust installs with CHTYPES_SOURCE_UNREACHABLE: io error: No such file or directory (os error 2).

Cause (measured on probe #514). Rust built every temporary name under the cache from unique_suffix(), which was a clock reading plus the thread id. That covers the temporary oci-layout, index.json, blob and verified.json files, the .tmp-* unpack directory, and the .stale-* aside. The main thread is ThreadId(1) in every process, so two processes that read the clock in the same step drew the same name. create_dir_all reuses an existing directory and fs::write truncates an existing file, so the two then shared one temporary path. The first rename won and the other failed with ENOENT.

The failing paths, from 1.1.0's path-naming messages, are:

  • <cache>/oci-layout and <cache>/index.json (the writes ensure_layout makes at startup), which account for nearly all of them;
  • .tmp-*/verified.json and blobs.

With a 32 MiB library and a forced collision, a process could also exit 0 naming an entry whose library was torn. verify then failed. The Linux runners' clock is ns-grained and darwin's is µs-grained, so the race is rare on Linux and more frequent on darwin or any coarse clock.

Fix (Rust only).

  • unique_suffix() is now the process id plus 64 random bits. They come from RandomState, whose SipHash keys are drawn from the OS random source in every process.
  • Every name is created exclusively: create_dir for directories and OpenOptions::create_new for files, retrying on AlreadyExists. A name another process holds is never reused, even if it repeats.
  • The entry being replaced is moved into a fresh .stale-* directory (<stale>/old), as Go, Python and TypeScript do. A failed rename removes its temporary file.

The other bindings do not share the flaw, and this PR does not change them (read from the code, and measured 0 below):

  • Go draws rand.Uint64 names and creates them exclusively (O_EXCL, Mkdir).
  • Python uses os.urandom with O_EXCL, and tempfile.mkdtemp.
  • TypeScript names are the pid plus randomBytes(6), and .stale-* comes from mkdtemp.

In all four, a vanished path in the scan or select path is already read as "absent": record reads, listings and the fault probe (read). docs/guides/fetch-v1.md §1 gains one sentence stating the rule for every binding.

Measurements (probe #514, hosted runners, 8 processes released together, one fresh cache per round)

The fix head is f6440ff for the Linux natural and planted legs, and e661922 for the coarse-clock and darwin legs. The Rust source is identical in both; the commits after f6440ff change only docs and the CHANGELOG.

Natural, Linux (ubuntu-latest; clock tsc, ns-grained), small layer:

binding 1.0.4 (38ceadc) 1.1.0 (d15083f) fix
rust 1/12800 (0/3200, 0/4800, 1/4800) 31/12800 (0/3200, 20/4800, 11/4800) 0/12800 (0/3200, 0/4800, 0/4800)
go 0/400 0/400 0/400
python 0/400 0/400 0/400
ts 0/400 0/400 0/400

The failures were:

  • 1.0.4: SOURCE_UNREACHABLE "io error: No such file or directory (os error 2)".
  • 1.1.0: CACHE_UNUSABLE "<cache>/oci-layout is unusable as a cache: unwritable (ENOENT)" ×28, and the same for <cache>/index.json ×3.
  • 08cfec9: rust 1/3200, with the exact CI message.

Natural, Linux, 32 MiB library:

binding 1.0.4 1.1.0 fix
rust 2/1200 1/1200 0/1200
go 0/240 0/240 0/240
python 0/240 0/240 0/240
ts 0/240 0/240 0/240

Coarse clock (100 µs), Linux, Rust:

leg 1.0.4 1.1.0 fix
small, clock rounded inside the old naming 72/1200 212/1200 (names read no clock)
32 MiB, clock rounded inside the old naming 79/480 89/480 (names read no clock)
small, unmodified binary under a clock shim not run 192/1200 0/1200
32 MiB, unmodified binary under a clock shim not run not run 0/480

The shim is an LD_PRELOAD that rounds CLOCK_REALTIME down to 100 µs. Each leg first checks that it takes effect. The 1.1.0 row validates it: 192 under the shim against 212 with the rounding patched into the naming.

Darwin (macos-latest, arm64, 3 CPUs; CLOCK_REALTIME is whole microseconds, 300000 of 300000 readings), small layer:

leg 1.1.0 fix
natural, rust 2/1200 (index.json unwritable ENOENT) 0/1200
natural, go / python / ts 0/200 each 0/200 each
coarse clock (100 µs, DYLD_INSERT_LIBRARIES shim), rust 46/800 0/800

1.0.4 was not run on darwin.

Planted collision, Linux, Rust (every other name draw repeats across the eight processes):

leg 1.0.4 1.1.0 fix
small 107/400 44/400 0/400
32 MiB 73/200: TORN-LIBRARY ×1224, 21 exits 0 with a library not matching its record 101/200: TORN-LIBRARY ×895, 24 such 0/200, no events

The messages were:

  • 1.0.4: SOURCE_UNREACHABLE "io error: No such file or directory"; ARTIFACT_CORRUPT "library <cache>/unpacked/sha256/.tmp-…/libchtypes.so: No such file or directory"; ARTIFACT_CORRUPT digest mismatch.
  • 1.1.0: CACHE_UNUSABLE unwritable (ENOENT) at oci-layout and index.json, plus the same ARTIFACT_CORRUPT pair.

Test plan

  • Four new unit tests in rust/src/ocifetch/layout.rs. With only a test seam added, they FAIL on 1.1.0's layout.rs (4 failed, 7 passed) and pass on this head (11 passed), measured in the probe's regression-tests jobs:
    • a temporary directory another installer holds is never shared;
    • a temporary file another writer holds is never shared;
    • an aside name another installer holds is never reused;
    • names carry the pid and differ.
  • policy-merge-check.py --selftest and --check-ci-names, lint-public, lint-spelling, lint-cited-paths, markdownlint-cli2 0.18.1, dprint 0.57.4 check, and rustfmt --check on layout.rs: all clean locally.
  • CI on e661922: every check passes except security (report only), which fails on main at d15083f too. The three skipped checks run on dispatch only.
  • Post-fix stress, from the tables above: 0 failures at the fix head in every leg (natural Linux and darwin in all four bindings, coarse clock, planted collision), at counts equal to or above the 1.0.4 and 1.1.0 legs.

Related issues

Refs #482. Folded into 1.1.0 (not yet tagged). CHANGELOG bullet under [1.1.0] / Fixed in Rust only, since no other binding changed.

🤖 Generated with Claude Code

EricAndrechek and others added 3 commits October 6, 2026 15:10
…ly (#482)

Rust named every temporary directory, temporary file and aside directory
under the cache from a clock reading and the thread id. The main thread is
ThreadId(1) in every process, so two processes that read the clock in the
same step chose the same name. They then shared one temporary directory
(create_dir_all reuses an existing one, fs::write truncates an existing
file), the first to rename it into place took the other's half-written
files with it, and the other failed on a path that had vanished:
CHTYPES_SOURCE_UNREACHABLE "io error: No such file or directory".

Every such name is now the process id and 64 random bits, and is created
exclusively (create_dir, create_new): a name another process holds is never
reused, another is drawn. The entry being replaced is moved into a fresh
.stale-* directory, as in Go, Python and TypeScript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…HANGELOG (#482)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@EricAndrechek
EricAndrechek added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit 6118ec6 Oct 6, 2026
@EricAndrechek
EricAndrechek deleted the fix/rust-install-names branch October 6, 2026 19:45
EricAndrechek added a commit that referenced this pull request Oct 6, 2026
sync: merge main (1.1.0, #516, #513, #517) into v2
EricAndrechek added a commit that referenced this pull request Oct 7, 2026
…t for more

The first CI run of the ABI v2 matrix took 6 to 14 s a round, so the
coarse-clock leg gets the natural leg's three rounds (30 installs per
binding). Under the 16% per-install failure rate #516 measured for the old
Rust naming under a coarse clock, 30 clean installs leave a 0.5% chance of
missing it, against 3% at two rounds. interop_rounds sets both counts for a
deliberate heavier dispatch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

1 participant