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 |