GCC Code Coverage Report


Directory: ../
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 0 / 350 / 350
Functions: -% 0 / 66 / 66
Branches: -% 0 / 1226 / 1226

test/unittest/UnitCompileCache.cpp
Line Branch Exec Source
1 // Copyright (c) 2021-2026 ChilliBits. All rights reserved.
2
3 #include <fstream>
4 #include <random>
5 #include <ranges>
6
7 #include <gtest/gtest.h>
8
9 #include <SourceFile.h>
10 #include <driver/Driver.h>
11 #include <global/CacheManager.h>
12 #include <global/GlobalResourceManager.h>
13
14 #include <llvm/TargetParser/Host.h>
15
16 // LCOV_EXCL_START
17
18 namespace spice::testing {
19
20 using namespace spice::compiler;
21
22 namespace {
23
24 std::filesystem::path makeUniqueCacheDir() {
25 std::random_device rd;
26 std::mt19937_64 rng(rd());
27 const std::string suffix = "spice-cache-test-" + std::to_string(rng());
28 std::filesystem::path dir = std::filesystem::temp_directory_path() / suffix;
29 std::filesystem::create_directories(dir);
30 return dir;
31 }
32
33 void writeDummyFile(const std::filesystem::path &path, const std::string &content) {
34 std::ofstream stream(path);
35 stream << content;
36 }
37
38 class CompileCacheTest : public ::testing::Test {
39 protected:
40 void SetUp() override {
41 cacheDir = makeUniqueCacheDir();
42 outputDir = makeUniqueCacheDir();
43 cliOptions.cacheDir = cacheDir;
44 cliOptions.outputDir = outputDir;
45 }
46
47 void TearDown() override {
48 std::error_code ec;
49 std::filesystem::remove_all(cacheDir, ec);
50 std::filesystem::remove_all(outputDir, ec);
51 }
52
53 CliOptions cliOptions;
54 std::filesystem::path cacheDir;
55 std::filesystem::path outputDir;
56 };
57
58 } // namespace
59
60 TEST_F(CompileCacheTest, ComputeCacheKeyIsDeterministic) {
61 const CacheManager manager(cliOptions);
62 const std::string source = "f<int> main() { return 0; }";
63 ASSERT_EQ(manager.computeCacheKey(source), manager.computeCacheKey(source));
64 }
65
66 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForDifferentContent) {
67 const CacheManager manager(cliOptions);
68 const std::string a = "f<int> main() { return 0; }";
69 const std::string b = "f<int> main() { return 1; }";
70 ASSERT_NE(manager.computeCacheKey(a), manager.computeCacheKey(b));
71 }
72
73 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForBuildMode) {
74 const std::string source = "f<int> main() { return 0; }";
75
76 cliOptions.buildMode = BuildMode::DEBUG;
77 const CacheManager managerDebug(cliOptions);
78 const std::string keyDebug = managerDebug.computeCacheKey(source);
79
80 cliOptions.buildMode = BuildMode::RELEASE;
81 const CacheManager managerRelease(cliOptions);
82 const std::string keyRelease = managerRelease.computeCacheKey(source);
83
84 ASSERT_NE(keyDebug, keyRelease);
85 }
86
87 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForOptLevel) {
88 const std::string source = "f<int> main() { return 0; }";
89
90 cliOptions.optLevel = OptLevel::O0;
91 const CacheManager managerO0(cliOptions);
92 const std::string keyO0 = managerO0.computeCacheKey(source);
93
94 cliOptions.optLevel = OptLevel::O3;
95 const CacheManager managerO3(cliOptions);
96 const std::string keyO3 = managerO3.computeCacheKey(source);
97
98 ASSERT_NE(keyO0, keyO3);
99 }
100
101 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForSanitizer) {
102 const std::string source = "f<int> main() { return 0; }";
103
104 cliOptions.instrumentation.sanitizer = Sanitizer::NONE;
105 const CacheManager managerNone(cliOptions);
106 const std::string keyNone = managerNone.computeCacheKey(source);
107
108 cliOptions.instrumentation.sanitizer = Sanitizer::ADDRESS;
109 const CacheManager managerAsan(cliOptions);
110 const std::string keyAsan = managerAsan.computeCacheKey(source);
111
112 ASSERT_NE(keyNone, keyAsan);
113 }
114
115 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForDebugInfo) {
116 const std::string source = "f<int> main() { return 0; }";
117
118 cliOptions.instrumentation.generateDebugInfo = false;
119 const CacheManager cmNoDebug(cliOptions);
120 const std::string keyNoDebug = cmNoDebug.computeCacheKey(source);
121
122 cliOptions.instrumentation.generateDebugInfo = true;
123 const CacheManager cmDebug(cliOptions);
124 const std::string keyDebug = cmDebug.computeCacheKey(source);
125
126 ASSERT_NE(keyNoDebug, keyDebug);
127 }
128
129 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForLTO) {
130 const std::string source = "f<int> main() { return 0; }";
131
132 cliOptions.useLTO = false;
133 const CacheManager managerNoLto(cliOptions);
134 const std::string keyNoLto = managerNoLto.computeCacheKey(source);
135
136 cliOptions.useLTO = true;
137 const CacheManager managerLto(cliOptions);
138 const std::string keyLto = managerLto.computeCacheKey(source);
139
140 ASSERT_NE(keyNoLto, keyLto);
141 }
142
143 TEST_F(CompileCacheTest, ComputeCacheKeyDiffersForDepCacheKeys) {
144 const CacheManager manager(cliOptions);
145 const std::string source = "f<int> main() { return 0; }";
146
147 const std::string keyNoDeps = manager.computeCacheKey(source);
148 const std::string keyWithDep = manager.computeCacheKey(source, {"dep-v1"});
149 const std::string keyWithDepChanged = manager.computeCacheKey(source, {"dep-v2"});
150
151 ASSERT_NE(keyNoDeps, keyWithDep);
152 ASSERT_NE(keyWithDep, keyWithDepChanged);
153 }
154
155 TEST_F(CompileCacheTest, ComputeCacheKeyIsOrderIndependentInDeps) {
156 const CacheManager manager(cliOptions);
157 const std::string source = "f<int> main() { return 0; }";
158 // Dep cache keys must be sorted internally, so call sites do not need to provide a particular
159 // order. This matters because dep traversal order is not stable across builds.
160 ASSERT_EQ(manager.computeCacheKey(source, {"a", "b", "c"}), manager.computeCacheKey(source, {"c", "a", "b"}));
161 }
162
163 TEST_F(CompileCacheTest, LookupExecutableMissReturnsFalse) {
164 const CacheManager manager(cliOptions);
165 std::filesystem::path resolved;
166 ASSERT_FALSE(manager.lookupExecutable({"key-a", "key-b"}, {"-lm"}, {}, resolved));
167 ASSERT_TRUE(resolved.empty());
168 }
169
170 TEST_F(CompileCacheTest, CacheExecutableRoundTrip) {
171 const CacheManager manager(cliOptions);
172
173 const std::filesystem::path executablePath = outputDir / "my-program";
174 writeDummyFile(executablePath, "executable-bytes");
175
176 const std::vector<std::string> objectKeys = {"obj-1", "obj-2"};
177 const std::vector<std::string> linkerFlags = {"-lm", "-lpthread"};
178
179 manager.cacheExecutable(objectKeys, linkerFlags, {}, executablePath);
180
181 std::filesystem::path resolved;
182 ASSERT_TRUE(manager.lookupExecutable(objectKeys, linkerFlags, {}, resolved));
183 ASSERT_TRUE(std::filesystem::exists(resolved));
184 ASSERT_EQ(cacheDir, resolved.parent_path());
185 }
186
187 TEST_F(CompileCacheTest, CacheExecutableNonExistingSourceIsNoop) {
188 const CacheManager manager(cliOptions);
189
190 const std::filesystem::path missingExecutable = outputDir / "does-not-exist";
191 const std::vector<std::string> objectKeys = {"obj-1"};
192 constexpr std::vector<std::string> linkerFlags;
193
194 manager.cacheExecutable(objectKeys, linkerFlags, {}, missingExecutable);
195
196 std::filesystem::path resolved;
197 ASSERT_FALSE(manager.lookupExecutable(objectKeys, linkerFlags, {}, resolved));
198 }
199
200 TEST_F(CompileCacheTest, LookupExecutableMissesWhenObjectKeysDiffer) {
201 const CacheManager manager(cliOptions);
202
203 const std::filesystem::path executablePath = outputDir / "program";
204 writeDummyFile(executablePath, "executable-bytes");
205 const std::vector<std::string> linkerFlags = {"-lm"};
206
207 manager.cacheExecutable({"obj-1", "obj-2"}, linkerFlags, {}, executablePath);
208
209 std::filesystem::path resolved;
210 ASSERT_FALSE(manager.lookupExecutable({"obj-1", "obj-3"}, linkerFlags, {}, resolved));
211 }
212
213 TEST_F(CompileCacheTest, LookupExecutableMissesWhenLinkerFlagsDiffer) {
214 const CacheManager manager(cliOptions);
215
216 const std::filesystem::path executablePath = outputDir / "program";
217 writeDummyFile(executablePath, "executable-bytes");
218 const std::vector<std::string> objectKeys = {"obj-1"};
219
220 manager.cacheExecutable(objectKeys, {"-lm"}, {}, executablePath);
221
222 std::filesystem::path resolved;
223 ASSERT_FALSE(manager.lookupExecutable(objectKeys, {"-lpthread"}, {}, resolved));
224 }
225
226 TEST_F(CompileCacheTest, LookupExecutableMissesWhenStaticLinkingDiffers) {
227 cliOptions.staticLinking = false;
228 const CacheManager manager(cliOptions);
229
230 const std::filesystem::path executablePath = outputDir / "program";
231 writeDummyFile(executablePath, "executable-bytes");
232 const std::vector<std::string> objectKeys = {"obj-1"};
233 const std::vector<std::string> linkerFlags = {"-lm"};
234
235 manager.cacheExecutable(objectKeys, linkerFlags, {}, executablePath);
236
237 // Same cacheDir, but a CacheManager configured with staticLinking=true should miss
238 cliOptions.staticLinking = true;
239 const CacheManager cmStatic(cliOptions);
240 std::filesystem::path resolved;
241 ASSERT_FALSE(cmStatic.lookupExecutable(objectKeys, linkerFlags, {}, resolved));
242 }
243
244 TEST_F(CompileCacheTest, LookupExecutableMissesWhenOutputContainerDiffers) {
245 cliOptions.outputContainer = OutputContainer::EXECUTABLE;
246 const CacheManager managerExec(cliOptions);
247
248 const std::filesystem::path executablePath = outputDir / "program";
249 writeDummyFile(executablePath, "executable-bytes");
250 const std::vector<std::string> objectKeys = {"obj-1"};
251 const std::vector<std::string> linkerFlags = {"-lm"};
252
253 managerExec.cacheExecutable(objectKeys, linkerFlags, {}, executablePath);
254
255 cliOptions.outputContainer = OutputContainer::SHARED_LIBRARY;
256 const CacheManager managerShared(cliOptions);
257 std::filesystem::path resolved;
258 ASSERT_FALSE(managerShared.lookupExecutable(objectKeys, linkerFlags, {}, resolved));
259 }
260
261 TEST_F(CompileCacheTest, LookupExecutableMissesWhenAdditionalSourceContentChanges) {
262 const CacheManager manager(cliOptions);
263
264 // C/C++ files referenced via @core.linker.additionalSource must contribute to the executable
265 // cache key, otherwise editing them would silently keep serving the previously linked binary.
266 const std::filesystem::path additionalSource = outputDir / "extra.c";
267 writeDummyFile(additionalSource, "int compute() { return 1; }\n");
268
269 const std::filesystem::path executablePath = outputDir / "program";
270 writeDummyFile(executablePath, "executable-bytes");
271
272 const std::vector<std::string> objectKeys = {"obj-1"};
273 const std::vector<std::string> linkerFlags = {"-lm"};
274 const std::vector<std::filesystem::path> additionalSources = {additionalSource};
275
276 manager.cacheExecutable(objectKeys, linkerFlags, additionalSources, executablePath);
277
278 std::filesystem::path resolved;
279 ASSERT_TRUE(manager.lookupExecutable(objectKeys, linkerFlags, additionalSources, resolved));
280
281 // Editing the additional source must invalidate the cached executable
282 writeDummyFile(additionalSource, "int compute() { return 2; }\n");
283 ASSERT_FALSE(manager.lookupExecutable(objectKeys, linkerFlags, additionalSources, resolved));
284 }
285
286 // Walks through a 3-file dependency chain (main -> utils -> math) using the real cache-key
287 // folding behavior, caches every file, then mutates math's source and verifies that the change
288 // cascades into utils and main: their cache keys move with the dep, so all three miss and must
289 // be recompiled. Also verifies that each restored entry holds only its own object file (no
290 // transitive accumulation, which would cause duplicate linker registrations downstream).
291 TEST_F(CompileCacheTest, MultiFileDependencyChangeForcesRecompilation) {
292 // Configure native target so that SourceFile construction can look up an LLVM target
293 cliOptions.targetTriple = llvm::Triple(llvm::Triple::normalize(llvm::sys::getProcessTriple()));
294 cliOptions.isNativeTarget = true;
295
296 // Place stub source files on disk (their contents are irrelevant: cache keys are derived from
297 // the in-memory "source" strings below and assigned to SourceFile::cacheKey directly).
298 const std::filesystem::path mathPath = outputDir / "math.spice";
299 const std::filesystem::path utilsPath = outputDir / "utils.spice";
300 const std::filesystem::path mainPath = outputDir / "main.spice";
301 writeDummyFile(mathPath, "");
302 writeDummyFile(utilsPath, "");
303 writeDummyFile(mainPath, "");
304
305 // GlobalResourceManager initializes LLVM targets and owns a CacheManager wired to cliOptions
306 GlobalResourceManager resourceManager(cliOptions);
307 CacheManager &manager = resourceManager.cacheManager;
308
309 // Sources that drive cache keys for the initial state
310 const std::string mathSrcV1 = "f<int> add(int a, int b) { return a + b; }";
311 const std::string utilsSrc = "import \"math\"; f<int> helper(int x) { return add(x, 1); }";
312 const std::string mainSrc = "import \"utils\"; f<int> main() { return helper(41); }";
313
314 SourceFile *math = resourceManager.createSourceFile(nullptr, "math", mathPath, false);
315 SourceFile *utils = resourceManager.createSourceFile(nullptr, "utils", utilsPath, false);
316 SourceFile *main = resourceManager.createSourceFile(nullptr, "main", mainPath, false);
317 math->isMainFile = false;
318 utils->isMainFile = false;
319 main->isMainFile = true;
320
321 // Wire up the dependency chain: main -> utils -> math
322 utils->dependencies["math"] = math;
323 main->dependencies["utils"] = utils;
324
325 // Assign cache keys as runImportCollector would: each file's key folds in its transitive
326 // dep cache keys, so changes to any dep propagate up the chain.
327 const auto recomputeKeys = [&] {
328 math->cacheKey = manager.computeCacheKey(mathSrcV1);
329 utils->cacheKey = manager.computeCacheKey(utilsSrc, {math->cacheKey});
330 main->cacheKey = manager.computeCacheKey(mainSrc, {utils->cacheKey, math->cacheKey});
331 };
332 recomputeKeys();
333
334 // Pretend the back end emitted object files into outputDir (cacheSourceFile picks them up there)
335 writeDummyFile(outputDir / "math.o", "math-v1-obj");
336 writeDummyFile(outputDir / "utils.o", "utils-obj");
337 writeDummyFile(outputDir / "main.o", "main-obj");
338
339 manager.cacheSourceFile(math);
340 manager.cacheSourceFile(utils);
341 manager.cacheSourceFile(main);
342
343 // Reset lookup-output fields between lookups, since they accumulate on each call
344 auto lookup = [&](SourceFile *sourceFile) {
345 sourceFile->cachedObjectFilePaths.clear();
346 sourceFile->sourceLinkerFlags.clear();
347 sourceFile->sourceAdditionalSourcePaths.clear();
348 return manager.lookupSourceFile(sourceFile);
349 };
350
351 // Phase 1: nothing changed since caching - every file hits and pulls its transitive dep
352 // objects (kept so runtime modules pulled in implicitly at symbol-table-building time still
353 // make it into the link for cache-restored files; the linker dedupes the overlap).
354 ASSERT_TRUE(lookup(math));
355 ASSERT_EQ(1u, math->cachedObjectFilePaths.size()); // math.o
356 ASSERT_TRUE(lookup(utils));
357 ASSERT_EQ(2u, utils->cachedObjectFilePaths.size()); // math.o + utils.o
358 ASSERT_TRUE(lookup(main));
359 ASSERT_EQ(3u, main->cachedObjectFilePaths.size()); // utils.o, math.o, main.o
360
361 // Phase 2: math's source changes -> its cache key changes -> utils and main keys also change
362 // (because the fold pulls in the new math key transitively). All three miss and recompile.
363 const std::string oldMathKey = math->cacheKey;
364 const std::string oldUtilsKey = utils->cacheKey;
365 const std::string oldMainKey = main->cacheKey;
366
367 const std::string mathSrcV2 = "f<int> add(int a, int b) { return a + b + 0; }";
368 math->cacheKey = manager.computeCacheKey(mathSrcV2);
369 utils->cacheKey = manager.computeCacheKey(utilsSrc, {math->cacheKey});
370 main->cacheKey = manager.computeCacheKey(mainSrc, {utils->cacheKey, math->cacheKey});
371 ASSERT_NE(oldMathKey, math->cacheKey);
372 ASSERT_NE(oldUtilsKey, utils->cacheKey);
373 ASSERT_NE(oldMainKey, main->cacheKey);
374
375 ASSERT_FALSE(lookup(math));
376 ASSERT_FALSE(lookup(utils));
377 ASSERT_FALSE(lookup(main));
378 }
379
380 // Provokes the bug that was fixed: before transitive dep cache keys were folded into a
381 // file's own cache key, a dependent whose source text was unchanged would keep cache-hitting
382 // against a stale object file even when one of its dependencies had been edited - causing
383 // the linker to consume out-of-date code. With the fold in place, mutating any dependency's
384 // cache key must propagate into every dependent's cache key, so the dependent's lookup misses,
385 // and it gets recompiled against the new dependency.
386 TEST_F(CompileCacheTest, DependencyChangeInvalidatesDependentCacheKey) {
387 const CacheManager manager(cliOptions);
388
389 // Initial cache keys for a dependent file (utils) and its single dependency (math)
390 const std::string mathSrc = "f<int> add(int a, int b) { return a + b; }";
391 const std::string utilsSrc = "import \"math\"; f<int> helper(int x) { return add(x, 1); }";
392
393 const std::string mathKeyV1 = manager.computeCacheKey(mathSrc);
394 const std::string utilsKeyV1 = manager.computeCacheKey(utilsSrc, {mathKeyV1});
395
396 // The dependent's own source text didn't change - only the dependency did.
397 const std::string mathSrcChanged = "f<int> add(int a, int b) { return a + b + 0; }";
398 const std::string mathKeyV2 = manager.computeCacheKey(mathSrcChanged);
399 ASSERT_NE(mathKeyV1, mathKeyV2);
400
401 // Without the fold, recomputing utils cache key here would yield the same value as before
402 // (since utilsSrc is unchanged) and the stale cached utils.o would be served. The fold makes
403 // the dependent's cache key a function of the dependency's cache key, so utils now misses too.
404 const std::string utilsKeyV2 = manager.computeCacheKey(utilsSrc, {mathKeyV2});
405 ASSERT_NE(utilsKeyV1, utilsKeyV2);
406
407 // Sanity check: without any dep change the dependent's cache key stays stable, so we don't
408 // pay unnecessary rebuilds when nothing actually moved.
409 ASSERT_EQ(utilsKeyV1, manager.computeCacheKey(utilsSrc, {mathKeyV1}));
410 }
411
412 // Provokes a real (non-mocked) regression: compile a two-file program once to populate the cache,
413 // then edit only the dependent file's source and recompile in a fresh GlobalResourceManager (this
414 // stands in for a new process run). The unchanged dependency must hit the cache for its object
415 // file, but its exported symbols still have to be resolvable by the dependent, since the dependent
416 // itself isn't a cache hit and needs to type-check against them. Before the fix, cache-restored
417 // files skipped SymbolTableBuilder/TypeChecker entirely, leaving their exportedNameRegistry empty
418 // and causing "undefined function" errors for every symbol the cache-hit dependency exported.
419 TEST_F(CompileCacheTest, CacheHitDependencyStillExposesSymbolsToChangedDependent) {
420 cliOptions.targetTriple = llvm::Triple(llvm::Triple::normalize(llvm::sys::getProcessTriple()));
421 cliOptions.isNativeTarget = true;
422
423 const std::filesystem::path mathPath = outputDir / "math.spice";
424 const std::filesystem::path mainPath = outputDir / "main.spice";
425
426 // The dependency stays untouched across both compiler runs.
427 writeDummyFile(mathPath, "public f<int> add(int a, int b) {\n return a + b;\n}\n");
428 writeDummyFile(mainPath, "import \"math\";\n\nf<int> main() {\n return add(1, 2);\n}\n");
429
430 // First "process run": compiles both files from scratch and populates the cache.
431 {
432 GlobalResourceManager resourceManager(cliOptions);
433 SourceFile *mainFile = resourceManager.createSourceFile(nullptr, MAIN_FILE_NAME, mainPath, false);
434 mainFile->runFrontEnd();
435 mainFile->runMiddleEnd();
436 ASSERT_TRUE(resourceManager.errorManager.softErrors.empty());
437 for (SourceFile *dependency : mainFile->dependencies | std::views::values)
438 dependency->runBackEnd();
439 mainFile->runBackEnd();
440 }
441
442 // Edit only main.spice. math.spice is unchanged, so it must still cache-hit on the next run.
443 writeDummyFile(mainPath, "import \"math\";\n\nf<int> main() {\n return add(1, 2) + 1;\n}\n");
444
445 // Second "process run" (fresh GlobalResourceManager, nothing carried over in memory).
446 {
447 GlobalResourceManager resourceManager(cliOptions);
448 SourceFile *mainFile = resourceManager.createSourceFile(nullptr, MAIN_FILE_NAME, mainPath, false);
449 mainFile->runFrontEnd();
450
451 SourceFile *mathFile = mainFile->dependencies.at("math");
452 ASSERT_TRUE(mathFile->restoredFromCache);
453 // This is the crux of the bug: a cache-restored file must still expose its symbols.
454 ASSERT_NE(nullptr, mathFile->getNameRegistryEntry("add"));
455
456 mainFile->runMiddleEnd();
457 ASSERT_TRUE(resourceManager.errorManager.softErrors.empty());
458
459 for (SourceFile *dependency : mainFile->dependencies | std::views::values)
460 dependency->runBackEnd();
461 mainFile->runBackEnd();
462 }
463 }
464
465 } // namespace spice::testing
466
467 // LCOV_EXCL_STOP
468