File: CompilationCacheTests.cs
Web Access
Project: src\src\Compilers\Server\VBCSCompilerTests\VBCSCompiler.UnitTests.csproj (VBCSCompiler.UnitTests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CommandLine;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Roslyn.Utilities;
using Roslyn.Test.Utilities;
using Xunit;
using Xunit.Abstractions;
 
namespace Microsoft.CodeAnalysis.CompilerServer.UnitTests
{
    public class CompilationCacheTests : TestBase
    {
        private readonly ICompilerServerLogger _logger;
 
        public CompilationCacheTests(ITestOutputHelper testOutputHelper)
        {
            _logger = new XunitCompilerServerLogger(testOutputHelper);
        }
 
        [Fact]
        public void TryCreate_ReturnsNull_WhenFeatureFlagNotSet()
        {
            var cache = CompilationCache.TryCreate(ParseArguments(["test.cs"]), _logger);
            Assert.Null(cache);
        }
 
        [Fact]
        public void TryCreate_ReturnsCache_WhenFeatureFlagUsesDefaultPath()
        {
            var expectedPath = PathUtilities.GetTempCachePath("roslyn-cache");
            var logMessages = new List<string>();
            var cache = CompilationCache.TryCreate(
                ParseArguments(["/features:use-global-cache", "test.cs"]),
                new CollectingLogger(logMessages));
 
            if (expectedPath is null)
            {
                Assert.Null(cache);
                Assert.Contains(logMessages, m => m.Contains("Compilation cache disabled because LocalApplicationData is unavailable.", StringComparison.Ordinal));
            }
            else
            {
                Assert.NotNull(cache);
                Assert.Contains(logMessages, m => m.Contains($"Compilation cache enabled at: {expectedPath}", StringComparison.Ordinal));
            }
        }
 
        [Fact]
        public void TryCreate_ReturnsCache_WhenFeatureFlagHasExplicitPath()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var logMessages = new List<string>();
            var cache = CompilationCache.TryCreate(
                ParseArguments([$"/features:{CompilerOptionParseUtilities.UseGlobalCacheFeatureFlag}={cacheDir}", "test.cs"]),
                new CollectingLogger(logMessages));
 
            Assert.NotNull(cache);
            Assert.Contains(logMessages, m => m.Contains($"Compilation cache enabled at: {cacheDir}", StringComparison.Ordinal));
        }
 
        [Fact]
        public void ComputeHashKey_IsDeterministic()
        {
            var key = "some deterministic key content";
            var hash1 = CompilationCache.ComputeHashKey(key);
            var hash2 = CompilationCache.ComputeHashKey(key);
 
            Assert.Equal(hash1, hash2);
            Assert.Equal(64, hash1.Length); // SHA-256 produces 32 bytes = 64 hex chars
            Assert.Equal(hash1, hash1.ToLowerInvariant()); // lowercase
        }
 
        [Fact]
        public void ComputeHashKey_DifferentInputs_ProduceDifferentHashes()
        {
            var hash1 = CompilationCache.ComputeHashKey("input A");
            var hash2 = CompilationCache.ComputeHashKey("input B");
            Assert.NotEqual(hash1, hash2);
        }
 
        [Fact]
        public void TryRestoreCachedResult_ReturnsFalse_WhenEntryMissing()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var outputDir = Temp.CreateDirectory().Path;
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, "Util.dll"),
            };
 
            var result = cache.TryRestoreCachedResult("Util.dll", "abc123", outputFiles, _logger);
 
            Assert.False(result);
            Assert.False(File.Exists(outputFiles.AssemblyPath));
        }
 
        [Fact]
        public void TryRestoreCachedResult_ReturnsTrue_WhenEntryExists()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "Util.dll";
            var hashKey = "abc123def456";
            var outputDir = Temp.CreateDirectory().Path;
 
            // Create the cache entry manually.
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Directory.CreateDirectory(entryDir);
            File.WriteAllBytes(Path.Combine(entryDir, "assembly"), [1, 2, 3]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, dllName),
            };
 
            var result = cache.TryRestoreCachedResult(dllName, hashKey, outputFiles, _logger);
 
            Assert.True(result);
            Assert.True(File.Exists(outputFiles.AssemblyPath));
            Assert.Equal([1, 2, 3], File.ReadAllBytes(outputFiles.AssemblyPath));
        }
 
        [Fact]
        public void TryRestoreCachedResult_RestoresAllOutputFiles()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = "fullhash";
            var outputDir = Temp.CreateDirectory().Path;
 
            // Create cache entry with all output files.
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Directory.CreateDirectory(entryDir);
            File.WriteAllBytes(Path.Combine(entryDir, "assembly"), [1]);
            File.WriteAllBytes(Path.Combine(entryDir, "pdb"), [2]);
            File.WriteAllBytes(Path.Combine(entryDir, "refassembly"), [3]);
            File.WriteAllBytes(Path.Combine(entryDir, "xmldoc"), [4]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, dllName),
                PdbPath = Path.Combine(outputDir, "MyLib.pdb"),
                RefAssemblyPath = Path.Combine(outputDir, "ref", dllName),
                XmlDocPath = Path.Combine(outputDir, "MyLib.xml"),
            };
 
            Directory.CreateDirectory(Path.Combine(outputDir, "ref"));
 
            var result = cache.TryRestoreCachedResult(dllName, hashKey, outputFiles, _logger);
 
            Assert.True(result);
            Assert.Equal([1], File.ReadAllBytes(outputFiles.AssemblyPath));
            Assert.Equal([2], File.ReadAllBytes(outputFiles.PdbPath!));
            Assert.Equal([3], File.ReadAllBytes(outputFiles.RefAssemblyPath!));
            Assert.Equal([4], File.ReadAllBytes(outputFiles.XmlDocPath!));
        }
 
        [Fact]
        public void TryRestoreCachedResult_ReturnsFalse_WhenRequestedOutputFileMissingFromCache()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = "assemblonly";
            var outputDir = Temp.CreateDirectory().Path;
 
            // Cache entry with only the assembly (no PDB).
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Directory.CreateDirectory(entryDir);
            File.WriteAllBytes(Path.Combine(entryDir, "assembly"), [10]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, dllName),
                PdbPath = Path.Combine(outputDir, "MyLib.pdb"),
            };
 
            var logMessages = new List<string>();
            var logger = new CollectingLogger(logMessages);
            var result = cache.TryRestoreCachedResult(dllName, hashKey, outputFiles, logger);
 
            // PDB was requested but not cached — should be treated as a cache miss.
            Assert.False(result);
            Assert.Contains(logMessages, m => m.Contains("Cache miss because entry is missing required output files:", StringComparison.Ordinal));
        }
 
        [Fact]
        public void TryRestoreCachedResult_ReturnsTrue_WhenPublishedEntryIsLocked()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = "locked-entry";
            var outputDir = Temp.CreateDirectory().Path;
            var logMessages = new List<string>();
            var logger = new CollectingLogger(logMessages);
 
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Directory.CreateDirectory(entryDir);
            File.WriteAllBytes(Path.Combine(entryDir, "assembly"), [1, 2, 3]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, dllName),
            };
 
            using var entryLock = AcquireEntryMutex(cache, dllName, hashKey);
            var result = cache.TryRestoreCachedResult(dllName, hashKey, outputFiles, logger);
 
            Assert.True(result);
            Assert.Equal([1, 2, 3], File.ReadAllBytes(outputFiles.AssemblyPath));
            Assert.DoesNotContain(logMessages, m => m.Contains("Cache miss because entry is being populated:", StringComparison.Ordinal));
        }
 
        [Fact]
        public void TryRestoreCachedResult_ReturnsFalseAndLogs_WhenMissingEntryIsLocked()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = "locked-miss";
            var outputDir = Temp.CreateDirectory().Path;
            var logMessages = new List<string>();
            var logger = new CollectingLogger(logMessages);
 
            Directory.CreateDirectory(Path.Combine(cacheDir, dllName));
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(outputDir, dllName),
            };
 
            using var entryLock = AcquireEntryMutex(cache, dllName, hashKey);
            var result = cache.TryRestoreCachedResult(dllName, hashKey, outputFiles, logger);
 
            Assert.False(result);
            Assert.False(File.Exists(outputFiles.AssemblyPath));
            Assert.Contains(logMessages, m => m.Contains("Cache miss because entry is being populated:", StringComparison.Ordinal));
        }
 
        [Fact]
        public void TryStoreResult_WritesAllOutputFiles()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = CompilationCache.ComputeHashKey("some key");
            var deterministicKey = """{ "compilation": "data" }""";
 
            // Create fake output files.
            var outputDir = Temp.CreateDirectory().Path;
            var assemblyPath = Path.Combine(outputDir, dllName);
            var pdbPath = Path.Combine(outputDir, "MyLib.pdb");
            var refPath = Path.Combine(outputDir, "ref", dllName);
            var xmlPath = Path.Combine(outputDir, "MyLib.xml");
            Directory.CreateDirectory(Path.Combine(outputDir, "ref"));
            File.WriteAllBytes(assemblyPath, [10, 20, 30]);
            File.WriteAllBytes(pdbPath, [40, 50]);
            File.WriteAllBytes(refPath, [60]);
            File.WriteAllBytes(xmlPath, [70, 80, 90]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = assemblyPath,
                PdbPath = pdbPath,
                RefAssemblyPath = refPath,
                XmlDocPath = xmlPath,
            };
 
            cache.TryStoreResult(dllName, hashKey, outputFiles, deterministicKey, _logger);
 
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Assert.Equal([10, 20, 30], File.ReadAllBytes(Path.Combine(entryDir, "assembly")));
            Assert.Equal([40, 50], File.ReadAllBytes(Path.Combine(entryDir, "pdb")));
            Assert.Equal([60], File.ReadAllBytes(Path.Combine(entryDir, "refassembly")));
            Assert.Equal([70, 80, 90], File.ReadAllBytes(Path.Combine(entryDir, "xmldoc")));
            Assert.Equal(deterministicKey, File.ReadAllText(Path.Combine(entryDir, dllName + ".key"), Encoding.UTF8));
        }
 
        [Fact]
        public void TryStoreResult_SkipsOptionalFiles_WhenNotPresent()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = CompilationCache.ComputeHashKey("key");
 
            // Only the assembly exists.
            var outputDir = Temp.CreateDirectory().Path;
            var assemblyPath = Path.Combine(outputDir, dllName);
            File.WriteAllBytes(assemblyPath, [1, 2, 3]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = assemblyPath,
                PdbPath = null,
                RefAssemblyPath = null,
                XmlDocPath = null,
            };
 
            cache.TryStoreResult(dllName, hashKey, outputFiles, "key", _logger);
 
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Assert.True(File.Exists(Path.Combine(entryDir, "assembly")));
            Assert.False(File.Exists(Path.Combine(entryDir, "pdb")));
            Assert.False(File.Exists(Path.Combine(entryDir, "refassembly")));
            Assert.False(File.Exists(Path.Combine(entryDir, "xmldoc")));
        }
 
        [Fact]
        public void TryStoreResult_DoesNotThrow_WhenAssemblyFileNotFound()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = "/nonexistent/path/Util.dll",
            };
 
            // No exception should escape even if the source file doesn't exist.
            cache.TryStoreResult("Util.dll", "hash", outputFiles, "key", _logger);
        }
 
        [Fact]
        public void TryStoreResult_OnlyOneWriterStoresEntry()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "MyLib.dll";
            var hashKey = CompilationCache.ComputeHashKey("shared key");
            var deterministicKey = """{ "compilation": "data" }""";
 
            var outputDir = Temp.CreateDirectory().Path;
            var assemblyPath = Path.Combine(outputDir, dllName);
            File.WriteAllBytes(assemblyPath, [10, 20, 30]);
 
            var outputFiles = new CompilationOutputFiles
            {
                AssemblyPath = assemblyPath,
            };
 
            var logger1Messages = new List<string>();
            var logger2Messages = new List<string>();
            var logger1 = new CollectingLogger(logger1Messages);
            var logger2 = new CollectingLogger(logger2Messages);
 
            var task1 = Task.Run(() => cache.TryStoreResult(dllName, hashKey, outputFiles, deterministicKey, logger1));
            var task2 = Task.Run(() => cache.TryStoreResult(dllName, hashKey, outputFiles, deterministicKey, logger2));
            Task.WaitAll(task1, task2);
 
            var entryDir = Path.Combine(cacheDir, dllName, hashKey);
            Assert.Equal([10, 20, 30], File.ReadAllBytes(Path.Combine(entryDir, "assembly")));
 
            var allMessages = logger1Messages.Concat(logger2Messages).ToArray();
            Assert.Equal(1, allMessages.Count(m => m.StartsWith("Cache stored:", StringComparison.Ordinal)));
            Assert.Contains(allMessages, m =>
                m.StartsWith("Cache store skipped because another writer is populating:", StringComparison.Ordinal) ||
                m.StartsWith("Cache store skipped because entry already exists:", StringComparison.Ordinal));
        }
 
        [Fact]
        public void RoundTrip_StoreAndRetrieve()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var dllName = "Round.dll";
            var deterministicKey = "the key";
            var hashKey = CompilationCache.ComputeHashKey(deterministicKey);
 
            var outputDir = Temp.CreateDirectory().Path;
            var assemblyPath = Path.Combine(outputDir, dllName);
            File.WriteAllBytes(assemblyPath, [0xAB, 0xCD]);
 
            var restoreDir = Temp.CreateDirectory().Path;
            var restoreFiles = new CompilationOutputFiles
            {
                AssemblyPath = Path.Combine(restoreDir, dllName),
            };
 
            // Initially no cache hit.
            Assert.False(cache.TryRestoreCachedResult(dllName, hashKey, restoreFiles, _logger));
 
            // Store the result.
            var storeFiles = new CompilationOutputFiles { AssemblyPath = assemblyPath };
            cache.TryStoreResult(dllName, hashKey, storeFiles, deterministicKey, _logger);
 
            // Now there should be a cache hit.
            Assert.True(cache.TryRestoreCachedResult(dllName, hashKey, restoreFiles, _logger));
            Assert.Equal([0xAB, 0xCD], File.ReadAllBytes(restoreFiles.AssemblyPath));
        }
 
        [Fact]
        public void LogCacheMiss_LogsNothing_WhenNoPriorEntries()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
            var logMessages = new List<string>();
            var logger = new CollectingLogger(logMessages);
 
            // Should complete without error and log only the miss line.
            cache.LogCacheMiss("NewLib.dll", "newhash", "current key content", logger);
 
            Assert.Single(logMessages);
            Assert.Contains("Cache miss:", logMessages[0]);
        }
 
        [Fact]
        public void LogCacheMiss_IncludesDiff_WhenPriorEntriesExist()
        {
            var cacheDir = Temp.CreateDirectory().Path;
            var cache = CreateCache(cacheDir);
 
            var dllName = "Util.dll";
            var oldHashKey = "oldhash";
            var oldKeyContent = """{ "version": "1", "source": "old" }""";
 
            // Create an old cache entry.
            var oldEntryDir = Path.Combine(cacheDir, dllName, oldHashKey);
            Directory.CreateDirectory(oldEntryDir);
            File.WriteAllText(Path.Combine(oldEntryDir, dllName + ".key"), oldKeyContent, Encoding.UTF8);
            // Also place a fake assembly so the directory looks like a valid entry.
            File.WriteAllBytes(Path.Combine(oldEntryDir, "assembly"), [1]);
 
            var logMessages = new List<string>();
            var logger = new CollectingLogger(logMessages);
 
            var newKeyContent = """{ "version": "1", "source": "new" }""";
            cache.LogCacheMiss(dllName, "newhash", newKeyContent, logger);
 
            // There should be a miss line plus at least one diff line.
            Assert.True(logMessages.Count >= 2);
            Assert.Contains(logMessages, m => m.Contains("Cache miss:"));
            Assert.Contains(logMessages, m => m.Contains("diff vs entry"));
        }
 
        [Fact]
        public void GetDeterministicKey_DiffersWithSourceLink()
        {
            var compilation = CSharpCompilation.Create(
                "TestAssembly",
                [CSharpSyntaxTree.ParseText("class C { }")],
                [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, deterministic: true));
 
            var emitOptions = new EmitOptions();
 
            var keyWithoutSourceLink = compilation.GetDeterministicKey(
                emitOptions: emitOptions,
                sourceLinkStream: null);
 
            using var sourceLinkStream = new MemoryStream("""{ "documents": { "*": "https://example.com/*" } }"""u8.ToArray());
            var keyWithSourceLink = compilation.GetDeterministicKey(
                emitOptions: emitOptions,
                sourceLinkStream: sourceLinkStream);
 
            Assert.NotEqual(keyWithoutSourceLink, keyWithSourceLink);
        }
 
        [Fact]
        public void GetDeterministicKey_DiffersWithDifferentSourceLinkContent()
        {
            var compilation = CSharpCompilation.Create(
                "TestAssembly",
                [CSharpSyntaxTree.ParseText("class C { }")],
                [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
                new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, deterministic: true));
 
            var emitOptions = new EmitOptions();
 
            using var sourceLinkStreamA = new MemoryStream("""{ "documents": { "*": "https://a.com/*" } }"""u8.ToArray());
            var keyA = compilation.GetDeterministicKey(
                emitOptions: emitOptions,
                sourceLinkStream: sourceLinkStreamA);
 
            using var sourceLinkStreamB = new MemoryStream("""{ "documents": { "*": "https://b.com/*" } }"""u8.ToArray());
            var keyB = compilation.GetDeterministicKey(
                emitOptions: emitOptions,
                sourceLinkStream: sourceLinkStreamB);
 
            Assert.NotEqual(keyA, keyB);
        }
 
        private static CompilationCache CreateCache(string cachePath)
        {
            return CompilationCache.TryCreate(
                ParseArguments([$"/features:{CompilerOptionParseUtilities.UseGlobalCacheFeatureFlag}={cachePath}", "test.cs"]),
                EmptyCompilerServerLogger.Instance)
                ?? throw new InvalidOperationException("Failed to create cache");
        }
 
        private static CommandLineArguments ParseArguments(string[] args)
            => CSharpCommandLineParser.Default.Parse(args, baseDirectory: Directory.GetCurrentDirectory(), sdkDirectory: null, additionalReferenceDirectories: null);
 
        private static IDisposable AcquireEntryMutex(CompilationCache cache, string dllName, string hashKey)
            => new NamedMutexLease(cache.GetCacheEntryMutexName(dllName, hashKey));
 
        private sealed class NamedMutexLease : IDisposable
        {
            private readonly ManualResetEventSlim _acquired = new(initialState: false);
            private readonly ManualResetEventSlim _release = new(initialState: false);
            private readonly Thread _thread;
            private Exception? _exception;
 
            internal NamedMutexLease(string mutexName)
            {
                _thread = new Thread(() =>
                {
                    try
                    {
                        using var mutex = new ServerNamedMutex(mutexName, out var createdNew);
                        if (!createdNew && !mutex.TryLock(timeoutMs: Timeout.Infinite))
                        {
                            throw new InvalidOperationException("Failed to acquire named mutex.");
                        }
 
                        _acquired.Set();
                        _release.Wait();
                    }
                    catch (Exception ex)
                    {
                        _exception = ex;
                        _acquired.Set();
                    }
                })
                {
                    IsBackground = true,
                };
 
                _thread.Start();
                _acquired.Wait();
 
                if (_exception is not null)
                {
                    _release.Set();
                    _thread.Join();
                    throw new InvalidOperationException("Failed to acquire named mutex for test.", _exception);
                }
            }
 
            public void Dispose()
            {
                _release.Set();
                _thread.Join();
                _acquired.Dispose();
                _release.Dispose();
            }
        }
 
        private sealed class CollectingLogger : ICompilerServerLogger
        {
            private readonly List<string> _messages;
 
            public CollectingLogger(List<string> messages) => _messages = messages;
 
            public bool IsLogging => true;
 
            public void Log(string message) => _messages.Add(message);
        }
    }
}