Hive Hive
Sign in

Manifest cache regression: cache is written but never reused

GitHub issue · Open

Metadata
Source
tuist/tuist #12201
Updated
Oct 21, 2026
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-derived → tuist 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).

T
Thongpak21 Sep 17, 2026

Hi @esnunes Update: root cause found, and my original diagnosis in this issue is wrong.

The manifest cache is not broken. On 4.208.0, a warm tuist generate --no-open --verbose run does zero manifest dumps and zero ProjectDescriptionHelpers rebuilds — and still takes 159s. The regression is in resource-glob exclusion, and it is a complexity change, not a caching one.

(Same disclosure as the issue body: investigated with Claude Code under my supervision, everything measured on my machine, happy to re-run anything.)

Bisected boundary

175 releases between 4.157.4 and 4.208.0, binary search, 5 back-to-back tuist generate --no-open runs per candidate with nothing changed in between:

tuist run 1 2 3 4 5
4.157.4 64s 10s 9s
4.164.2 60s 10s
4.169.0 14s 10s 9s 10s 9s
4.169.1 93s 55s 89s 136s 161s
4.170.0 91s 143s 150s 70s 17s
4.170.1 96s 69s 136s 160s 116s
4.171.3 190s 157s
4.188.5 203s 168s
4.208.0 157s 164s 173s
4.210.0-canary.6 178s 158s 168s

Last good: 4.169.0. First bad: 4.169.1. Still reproduces on the latest canary I had installed.

Where the time goes

Timestamping every verbose log line shows one single silent block; the second largest gap in the whole run is 0.2s:

Target FirebaseFirestoreTestingSupport ignored by product type
Minimum deployment targets supported by the selected SDKs: SDKDeploymentTargets(iOS: "12.0", ...)
>>> 127.0s with no output <<<
Transforming project <first project>: Deleting /Derived directory

All graph mappers together account for ~1.3s (StaticXCFrameworkModuleMapGraphMapper 0.571s, TargetsToCacheBinariesGraphMapper 0.528s, …), so the time is in package/target mapping.

I built 4.208.0 from source to get symbols. sample(1) over a 30s window inside that block, 20,843 samples, essentially one stack:

ResourceFileElement.from(manifest:generatorPaths:fileSystem:includeFiles:) ResourceFileElement+ManifestMapper.swift:88
└ globFiles #1 (_:excluding:) ResourceFileElement+ManifestMapper.swift:44
└ closure #1 in globFiles ResourceFileElement+ManifestMapper.swift:45
├ AbsolutePath.contains(_:) 10,632 samples (51%)
│ └ UNIXPath.components.getter Path.swift:623
└ _ArrayBuffer._consumeAndCreateNew / Collection.split / Array.append

The change that did it

From 9303ffeb (“refactor(cli): replace FileHandler with FileSystem”, #10040), the only CLI commit in 4.169.1 that touches this path:

files = try await fileSystem
.throwingGlob(directory: .root, include: [String(path.pathString.dropFirst())])
.collect()
- .filter(includeFiles)
- .filter { !excluded.contains($0) }
+ .filter { path in
+ !excluded.contains(where: { path.isDescendantOfOrEqual(to: $0) })
+ }
+ .concurrentFilter { try await includeFiles($0) }

excluded is a Set<AbsolutePath> that has already been expanded to concrete file paths a few lines above (excluded.formUnion(globs)). So the exclusion test went from one hash lookup per file to a linear scan of the whole set, and every comparison re-splits both paths into components via UNIXPath.components. That is O(files x excluded x depth) with an array allocation per comparison. #10957 (“avoid expanding recursive exclusion globs”) shrinks excluded, but the scan is still there, which matches this still being slow on 4.208.0 / 4.210 canary.

Fix and measurement

Walking the candidate’s parents against the set keeps the semantics and removes the scan:

- !excluded.contains(where: { path.isDescendantOfOrEqual(to: $0) })
+ var candidate = path
+ while true {
+ if excluded.contains(candidate) { return false }
+ if candidate.isRoot { return true }
+ candidate = candidate.parentDirectory
+ }

Measured with my own release build of 4.208.0, same machine, same workspace:

build generate (warm)
self-built 4.208.0, unpatched 186s / 207s
self-built 4.208.0, patched 10s / 8s / 8s

The generated project.pbxproj is byte-identical between the patched and unpatched self-built binaries (0 diff lines across all generated projects and workspaces), so this is a pure complexity fix. Happy to open a PR with this if you want it in that shape.

Project shape

114,366 files / 33,818 directories / 456 symlinks in the repo; workspace of 9 generated projects plus 39 resolved packages from Tuist/Package.swift (Tuist/.build is ~3.1 GB). Xcode 26.3, macOS 26.

Ruled out along the way

  • Manifest / helpers cache — warm 4.208.0 does zero manifest dumps and zero helpers rebuilds, still 159s.
  • URL.resourceValues(forKeys:) per entry in FileSystem’s Glob.directoryDestination — replacing it with lstat changed nothing (131ms vs 157ms globbing our source tree).
  • FileSystem glob vs the old FileManager.enumerator traversal — 143ms vs 124ms on the same tree.
  • Machine-level file-system slowness — raw lstat here is 2.5 µs, getattrlist 1.9 µs.