Hive Hive
Sign in

Manifest cache regression: cache is written but never reused

GitHub issue · Open

Metadata
Source
tuist/tuist #12201
Updated
Aug 2, 2026
Domains
Cache
Details

What happened?

Note: This issue was investigated and summarized by an AI assistant (Claude Code) under my supervision. All timings and cache observations were measured on my machine, and I can re-run or verify anything on request.

On recent Tuist versions, every tuist generate recompiles ProjectDescriptionHelpers and all project manifests, even when absolutely nothing changed between two consecutive invocations. The manifest cache is written on every run (same cache keys, files rewritten in place) but never reused. On 4.157.4 the cache works as expected.

Measured on the same repo, same commit, same machine, back-to-back runs of tuist generate --no-open:

tuist 1st run consecutive runs
4.157.4 63s 10.3s / 12.5s
4.171.3 (clean Tuist/.build) 38s 92.6s / 125.0s ❌ (flaky)
4.183.1 156.6s ❌
4.190.0 157.6s ❌
4.203.1 161.8s 163.2s / 155.0s / 178.7s

What I ruled out:

  • Environment instability — two consecutive env | sort dumps from the exact same invocation context are byte-identical, so environmentHash inputs are stable.
  • Cache-key churn~/.cache/tuist/Manifests/1.<md5> filenames are stable across runs (zero new names); the same entries get rewritten in place on every run, so the write path computes the same keys while the read path rejects them.
  • Manifest sandboxing — setting generationOptions: .options(disableSandbox: true) has no effect (still ~155s warm).
  • Expected behavior: adding --verbose changes the helpers cache key (TUIST_* env vars feed the hash) — that part is fine.

Given CachedManifestLoader reuses a cached manifest only when cacheVersion, tuistVersion and all of manifestHash / helpersHash / pluginsHash / environmentHash / disableSandboxHash match, and manifest files, tuist version, environment and the sandbox flag are provably constant here, the failing comparison seems to be helpersHash (or pluginsHash) being computed differently between store and lookup.

How do we reproduce it?

Project shape (private repo, but the shape should be all that matters):

  • Workspace with 9 generated projects (1 app + 8 module projects), ~75 workspace refs
  • 6 files in Tuist/ProjectDescriptionHelpers
  • Tuist/Package.swift with 18 remote packages (39 resolved packages via tuist install)
  • No plugins. Tuist.swift only sets compatibleXcodeVersions.

Steps:

  1. tuist install
  2. tuist generate --no-open (first run — expected to be slow)
  3. tuist generate --no-open again, changing nothing
  4. On 4.157.4 step 3 takes ~10s; on 4.171.3+ it takes roughly as long as step 2 (~155–180s here). Running with --verbose shows Built ProjectDescriptionHelpers on every run, followed by a long silent phase while every Project.swift recompiles.
  5. Observe ~/.cache/tuist/Manifests/* mtimes: the same entries are rewritten on every run (cache is stored but never reused).

Happy to run a patched build, add debug logging, or bisect 4.158–4.170 if that helps narrow it down.

Error log

No error — generation succeeds. Timing log from tuist generate --no-open --verbose on a warm cache (4.203.1, total 203s):

00:29:18 Loading and constructing the graph
00:29:38 Built ProjectDescriptionHelpers in (20.431s) ← rebuilt every run
00:29:41 [external package targets ignored/loaded — fast]
[~163s of silence: every Project.swift manifest recompiles]
00:32:24 Transforming project ...
00:32:42 Total time taken: 203.763s

macOS version

26.5.2

Tuist version

4.203.1

Xcode version

26.3

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
E
esnunes Aug 3, 2026

Thanks for the detailed report — the ruling-out work was really useful.

Our current suspicion matches yours: helpersHash. It’s the one key shared by both symptoms — it names the compiled helpers module directory (~/.cache/tuist/ProjectDescriptionHelpers/<hash>) and is embedded in every manifest cache entry, so if it’s unstable across runs you’d see exactly this: helpers rebuilt every run and every Manifests/1.<md5> entry rewritten.

We’ll try to reproduce on our side and work on it regardless, but two things from your machine would speed this up a lot:

1. Which hash field actually moves. No patched build needed:

cd ~/.cache/tuist/Manifests
dump() { for f in 1.*; do jq -c --arg f "$f" '{f:$f,h:.hashes}' "$f"; done | sort; }
dump > /tmp/before.txt
tuist generate --no-open
dump > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt | head

Honestly, “yes, helpersHash changed” / “no, it was something else” is the whole signal — you don’t need to paste anything. If you do paste, please keep the jq selector: the sibling manifest field in those files is your full manifest JSON base64-encoded.

2. Whether xcrun writes anything to stderr:

xcrun --sdk macosx --show-sdk-version >/tmp/sdk.out 2>/tmp/sdk.err
wc -c /tmp/sdk.out /tmp/sdk.err

A non-zero size on /tmp/sdk.err would confirm a specific theory we have. Again, just the byte count is enough — the contents can include paths with your username.

Also useful if it’s easy: does ls -1t ~/.cache/tuist/ProjectDescriptionHelpers gain a new directory after each run?

T
Thongpak21 Aug 3, 2026

Thanks for the fast triage @esnunes — ran both checks (plus one more that I think narrows it down further).

1) Which hash field changes → none of them

Clean protocol, everything pinned to 4.203.1: rm -rf Tuist/.build/tuist-derivedtuist install → warm-up tuist generate --no-open (231s) → snapshot every ~/.cache/tuist/Manifests/1.* entry’s {tuistVersion, hashes} → measured tuist generate --no-open (149s) → snapshot again and diff:

entries: before=63 after=63 new_files=0 (22 entries written by 4.203.1)
CHANGED FIELDS across common entries: NONE — all hash fields identical

manifestHash / helpersHash / pluginsHash / disableSandboxHash are byte-identical before vs after the run, and no new cache filenames appeared.

2) xcrun stderr → empty

$ xcrun --sdk macosx --show-sdk-version 1>/tmp/sdk.out 2>/tmp/sdk.err
stdout: 26.2
stderr bytes: 0

Stable 26.2 across 3 consecutive invocations, so the stderr-pollution theory doesn’t hold on this machine (macOS 26.5.2 / Xcode 26.3).

3) New ProjectDescriptionHelpers entry per run → no

No new directory appeared, and the compiled libProjectDescriptionHelpers.dylib inside the existing entry has an mtime hours before both runs — so helpers were served from cache during this protocol. (The “helpers rebuilt every run” in my original report appears to have been contaminated by manifest edits and --verbose env changes between runs on my side — under this controlled protocol the helpers cache hits. Sorry for the misdirection.)

The part that looks like the actual bug

Even though every stored hash matches what the store path computes, the Manifests/1.* files were rewritten in place during the measured run (mtimes updated, content identical) and the run still took 149s recompiling every Project.swift. So the lookup path seems to fail before the hash comparison — a read/decode failure or a different key derivation on load vs store — rather than any hash being non-deterministic.

One more data point: a minimal project (helpers + Environment. access + 9-project workspace + local path package + empty Tuist/Package.swift) does not reproduce — warm generates are ~0.3s. The one thing I couldn’t add to the minimal repro is real resolved external dependencies (our project has 18 remote packages / 39 resolved), because a fresh tuist install on this machine fails with error: unknown option --cache-path; Did you mean --package-path? from the SwifterPM layer (happy to file that separately — warm ~/.cache/swifterpm masks it on the main repo).