File: EditAndContinue\CommittedSolution.cs
Web Access
Project: src\src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj (Microsoft.CodeAnalysis.Features)
// 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.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.Collections;
using Microsoft.CodeAnalysis.Debugging;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using Roslyn.Utilities;
 
namespace Microsoft.CodeAnalysis.EditAndContinue;
 
/// <summary>
/// Encapsulates access to the last committed solution.
/// We don't want to expose the solution directly since access to documents must be gated by out-of-sync checks.
/// </summary>
internal sealed class CommittedSolution(DebuggingSession debuggingSession, Solution solution)
{
    internal enum DocumentState
    {
        None = 0,
 
        /// <summary>
        /// The current document content does not match the content the module was compiled with.
        /// This document state may change to <see cref="MatchesBuildOutput"/> or <see cref="DesignTimeOnly"/>.
        /// </summary>
        OutOfSync = 1,
 
        /// <summary>
        /// It hasn't been possible to determine whether the current document content does matches the content 
        /// the module was compiled with due to error while reading the PDB or the source file.
        /// This document state may change to <see cref="MatchesBuildOutput"/> or <see cref="DesignTimeOnly"/>.
        /// </summary>
        Indeterminate = 2,
 
        /// <summary>
        /// The document is not compiled into the module. It's only included in the project
        /// to support design-time features such as completion, etc.
        /// This is a final state. Once a document is in this state it won't switch to a different one.
        /// </summary>
        DesignTimeOnly = 3,
 
        /// <summary>
        /// The current document content matches the content the built module was compiled with.
        /// This is a final state. Once a document is in this state it won't switch to a different one.
        /// </summary>
        MatchesBuildOutput = 4
    }
 
    /// <summary>
    /// Current solution snapshot used as a baseline for calculating EnC delta.
    /// </summary>
    private Solution _solution = solution;
 
    /// <summary>
    /// Tracks stale projects. Changes in these projects are ignored and their representation in the <see cref="_solution"/> does not match the binaries on disk.
    /// The value is the MVID of the module at the time it was determined to be stale (source code content did not match the PDB).
    /// A build that updates the binary to new content (that presumably matches the source code) will update the MVID. When that happens we unstale the project.
    /// 
    /// Build of a multi-targeted project that sets <c>SingleTargetBuildForStartupProjects</c> msbuild property (e.g. MAUI) only 
    /// builds TFM that's active. Other TFMs of the projects remain unbuilt or stale (from previous build).
    /// 
    /// A project is removed from this set if it's rebuilt.
    /// 
    /// Lock <see cref="_guard"/> to update.
    /// </summary>
    private ImmutableDictionary<ProjectId, StaleProjectInfo> _staleProjects = ImmutableDictionary<ProjectId, StaleProjectInfo>.Empty;
 
    /// <summary>
    /// Implements workaround for https://github.com/dotnet/project-system/issues/5457.
    /// 
    /// When debugging is started we capture the current solution snapshot.
    /// The documents in this snapshot might not match exactly to those that the compiler used to build the module 
    /// that's currently loaded into the debuggee. This is because there is no reliable synchronization between
    /// the (design-time) build and Roslyn workspace. Although Roslyn uses file-watchers to watch for changes in 
    /// the files on disk, the file-changed events raised by the build might arrive to Roslyn after the debugger
    /// has attached to the debuggee and EnC service captured the solution.
    /// 
    /// Ideally, the Project System would notify Roslyn at the end of each build what the content of the source
    /// files generated by various targets is. Roslyn would then apply these changes to the workspace and 
    /// the EnC service would capture a solution snapshot that includes these changes.
    /// 
    /// Since this notification is currently not available we check the current content of source files against
    /// the corresponding checksums stored in the PDB. Documents for which we have not observed source file content 
    /// that maches the PDB checksum are considered <see cref="DocumentState.OutOfSync"/>. 
    /// 
    /// Some documents in the workspace are added for design-time-only purposes and are not part of the compilation
    /// from which the assembly is built. These documents won't have a record in the PDB and will be tracked as 
    /// <see cref="DocumentState.DesignTimeOnly"/>.
    /// 
    /// A document state can only change from <see cref="DocumentState.OutOfSync"/> to <see cref="DocumentState.MatchesBuildOutput"/>.
    /// Once a document state is <see cref="DocumentState.MatchesBuildOutput"/> or <see cref="DocumentState.DesignTimeOnly"/>
    /// it will never change.
    /// 
    /// Lock <see cref="_guard"/> to access.
    /// </summary>
    private readonly Dictionary<DocumentId, DocumentState> _documentState = [];
 
    private readonly object _guard = new();
 
    // test only
    internal void Test_SetDocumentState(DocumentId documentId, DocumentState state)
    {
        lock (_guard)
        {
            _documentState[documentId] = state;
        }
    }
 
    // test only
    internal ImmutableArray<(DocumentId id, DocumentState state)> Test_GetDocumentStates()
    {
        lock (_guard)
        {
            return _documentState.SelectAsArray(e => (e.Key, e.Value));
        }
    }
 
    public Project? GetProject(ProjectId id)
        => _solution.GetProject(id);
 
    public Project GetRequiredProject(ProjectId id)
        => _solution.GetRequiredProject(id);
 
    public ImmutableDictionary<ProjectId, StaleProjectInfo> StaleProjects
        => _staleProjects;
 
    public ImmutableArray<DocumentId> GetDocumentIdsWithFilePath(string path)
        => _solution.GetDocumentIdsWithFilePath(path);
 
    public bool ContainsDocument(DocumentId documentId)
        => _solution.ContainsDocument(documentId);
 
    /// <summary>
    /// Returns a document snapshot for given <see cref="Document"/> whose content exactly matches
    /// the source file used to compile the binary currently loaded in the debuggee. Returns null
    /// if it fails to find a document snapshot whose content hash maches the one recorded in the PDB.
    /// 
    /// The result is cached and the next lookup uses the cached value, including failures unless <paramref name="reloadOutOfSyncDocument"/> is true.
    /// </summary>
    public async Task<(Document? Document, DocumentState State)> GetDocumentAndStateAsync(Document currentDocument, CancellationToken cancellationToken, bool reloadOutOfSyncDocument = false)
    {
        Solution solution;
        var documentState = DocumentState.None;
        var documentId = currentDocument.Id;
 
        lock (_guard)
        {
            solution = _solution;
            _documentState.TryGetValue(documentId, out documentState);
        }
 
        var committedDocument = solution.GetDocument(documentId);
 
        switch (documentState)
        {
            case DocumentState.MatchesBuildOutput:
                // Note: committedDocument is null if we previously validated that a document that is not in
                // the committed solution is also not in the PDB. This means the document has been added during debugging.
                return (committedDocument, documentState);
 
            case DocumentState.DesignTimeOnly:
                return (null, documentState);
 
            case DocumentState.OutOfSync:
                if (reloadOutOfSyncDocument)
                {
                    break;
                }
 
                return (null, documentState);
 
            case DocumentState.Indeterminate:
                // Previous attempt resulted in a read error. Try again.
                break;
 
            case DocumentState.None:
                // Have not seen the document before, the document is not in the solution, or the document is source generated.
 
                if (committedDocument == null)
                {
                    var sourceGeneratedDocument = await solution.GetSourceGeneratedDocumentAsync(documentId, cancellationToken).ConfigureAwait(false);
                    if (sourceGeneratedDocument != null)
                    {
                        // source generated files are never out-of-date:
                        return (sourceGeneratedDocument, DocumentState.MatchesBuildOutput);
                    }
 
                    // The current document is source-generated therefore the corresponding one is not present in the base solution.
                    if (currentDocument is SourceGeneratedDocument)
                    {
                        return (null, DocumentState.MatchesBuildOutput);
                    }
                }
 
                break;
        }
 
        // Document compiled into the baseline DLL/PDB may have been added to the workspace
        // after the committed solution snapshot was taken.
        var document = committedDocument ?? currentDocument;
        if (document == null)
        {
            // Document has been deleted.
            return (null, DocumentState.None);
        }
 
        // TODO: Handle case when the old project does not exist and needs to be added. https://github.com/dotnet/roslyn/issues/1204
        if (committedDocument == null && !solution.ContainsProject(document.Project.Id))
        {
            // Document in a new project that does not exist in the committed solution.
            // Pretend this document is design-time-only and ignore it.
            return (null, DocumentState.DesignTimeOnly);
        }
 
        if (document.DocumentState.IgnoreForEditAndContinue())
        {
            return (null, DocumentState.DesignTimeOnly);
        }
 
        Contract.ThrowIfNull(document.FilePath);
 
        var sourceText = await document.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
        var sourceTextVersion = (committedDocument == null) ? await document.GetTextVersionAsync(cancellationToken).ConfigureAwait(false) : default;
 
        var (maybeMatchingSourceText, maybePdbHasDocument) = await TryGetMatchingSourceTextAsync(document, sourceText, currentDocument, cancellationToken).ConfigureAwait(false);
 
        lock (_guard)
        {
            // only listed document states can be changed:
            if (_documentState.TryGetValue(documentId, out documentState) &&
                documentState != DocumentState.OutOfSync &&
                documentState != DocumentState.Indeterminate)
            {
                return (document, documentState);
            }
 
            DocumentState newState;
            Document? matchingDocument;
 
            if (!maybePdbHasDocument.HasValue)
            {
                // Unable to determine due to error reading the PDB.
                return (document, DocumentState.Indeterminate);
            }
 
            if (!maybePdbHasDocument.Value)
            {
                // Source file is not listed in the PDB.
                // It could either be a newly added document or a design-time-only document (e.g. WPF .g.i.cs files).
                // We can't distinguish between newly added document and newly added design-time-only document.
                matchingDocument = null;
                newState = (committedDocument != null) ? DocumentState.DesignTimeOnly : DocumentState.MatchesBuildOutput;
            }
            else if (!maybeMatchingSourceText.HasValue)
            {
                // Unable to determine due to error reading the source file.
                return (document, DocumentState.Indeterminate);
            }
            else
            {
                // The following patches the current committed solution with the actual baseline content of the document, if we could retrieve it.
                // This patch is temporary, in effect for the current delta calculation. Once the changes are applied and committed we 
                // update the committed solution to the latest snapshot of the main workspace solution. This operation drops the changes made here.
                // That's ok since we only patch documents that have been modified and therefore their new versions will be the correct baseline for the 
                // next delta calculation. The baseline content loaded here won't be needed anymore.
 
                // Document exists in the PDB but not in the committed solution.
                // Add the document to the committed solution with its current (possibly out-of-sync) text.
                if (committedDocument == null)
                {
                    // TODO: Handle case when the old project does not exist and needs to be added. https://github.com/dotnet/roslyn/issues/1204
                    Debug.Assert(_solution.ContainsProject(documentId.ProjectId));
 
                    // TODO: Use API proposed in https://github.com/dotnet/roslyn/issues/56253.
                    _solution = _solution.AddDocument(DocumentInfo.Create(
                        documentId,
                        name: document.Name,
                        sourceCodeKind: document.SourceCodeKind,
                        folders: document.Folders,
                        loader: TextLoader.From(TextAndVersion.Create(sourceText, sourceTextVersion, document.Name)),
                        filePath: document.FilePath,
                        isGenerated: document.State.Attributes.IsGenerated)
                        .WithDesignTimeOnly(document.State.Attributes.DesignTimeOnly)
                        .WithDocumentServiceProvider(document.State.DocumentServiceProvider));
                }
 
                var matchingSourceText = maybeMatchingSourceText.Value;
                if (matchingSourceText != null)
                {
                    if (committedDocument != null && sourceText.ContentEquals(matchingSourceText))
                    {
                        matchingDocument = document;
                    }
                    else
                    {
                        _solution = _solution.WithDocumentText(documentId, matchingSourceText, PreservationMode.PreserveValue);
                        matchingDocument = _solution.GetDocument(documentId);
                    }
 
                    newState = DocumentState.MatchesBuildOutput;
                }
                else
                {
                    matchingDocument = null;
                    newState = DocumentState.OutOfSync;
                }
            }
 
            _documentState[documentId] = newState;
            return (matchingDocument, newState);
        }
    }
 
    private async ValueTask<(Optional<SourceText?> matchingSourceText, bool? hasDocument)> TryGetMatchingSourceTextAsync(Document document, SourceText sourceText, Document currentDocument, CancellationToken cancellationToken)
    {
        Contract.ThrowIfNull(document.FilePath);
 
        var maybePdbHasDocument = TryReadSourceFileDebugInfo(document, sourceText.Encoding, out var requiredChecksum, out var checksumAlgorithm, out var defaultEncoding);
 
        var maybeMatchingSourceText = (maybePdbHasDocument == true)
            ? await TryGetMatchingSourceTextAsync(
                debuggingSession.SessionLog,
                sourceText,
                document.FilePath,
                currentDocument,
                debuggingSession.SourceTextProvider,
                requiredChecksum,
                checksumAlgorithm,
                defaultEncoding,
                cancellationToken).ConfigureAwait(false)
            : default;
 
        return (maybeMatchingSourceText, maybePdbHasDocument);
    }
 
    /// <summary>
    /// Try to get ahold of source code snapshot that matches the content of the source file when the compiler read it during build.
    /// This is not always possible since the file on disk can be changed from outside of the IDE at any point in time, before we have 
    /// the opportunity to capture it.
    /// 
    /// Possible improvements:
    /// 1) check if the PDB contains embedded source for the document (https://github.com/dotnet/roslyn/issues/82879)
    /// 2) send request to VBCSCompiler for the content of the file; if the project was just built it might still be loaded in the server (https://github.com/dotnet/sdk/issues/53550)
    /// </summary>
    /// <remarks>
    /// dotnet-watch captures the content of all source files when the session starts. Such approach would be too slow for the IDE.
    /// It's necessary for dotnet-watch since watch is entirely dependent on watching file system changes and it does not have any other way to find 
    /// the baseline content of a modified source file. Although it could use [1] and [2] above, these are not always available.
    /// 
    /// In the IDE we prefer to not block project launching on hydrating the content of all source files since we don't even know
    /// whether the user intends to use Hot Reload or not. In fact, in most cases the user does not make any changes and just wants to run or debug an app.
    /// Unlike dotnet-watch, which is explicitly used for Hot Reload and capturing the source content is part of project loading.
    /// </remarks>
    private static async ValueTask<Optional<SourceText?>> TryGetMatchingSourceTextAsync(
        TraceLog log,
        SourceText sourceText,
        string filePath,
        Document? currentDocument,
        IPdbMatchingSourceTextProvider sourceTextProvider,
        ImmutableArray<byte> requiredChecksum,
        SourceHashAlgorithm checksumAlgorithm,
        Encoding? defaultEncoding,
        CancellationToken cancellationToken)
    {
        if (IsMatchingSourceText(sourceText, requiredChecksum, checksumAlgorithm))
        {
            return sourceText;
        }
 
        if (currentDocument != null)
        {
            var currentDocumentSourceText = await currentDocument.GetValueTextAsync(cancellationToken).ConfigureAwait(false);
            if (IsMatchingSourceText(currentDocumentSourceText, requiredChecksum, checksumAlgorithm))
            {
                return currentDocumentSourceText;
            }
        }
 
        var text = await sourceTextProvider.TryGetMatchingSourceTextAsync(filePath, requiredChecksum, checksumAlgorithm, cancellationToken).ConfigureAwait(false);
        if (text != null)
        {
            // Note: the encoding and the checksum of the resulting text does not need to be correct,
            // since the provider already verified that the decoded text string matches the checksum in the PDB.
            // If we needed it to be exact for some reason we would need to update TryGetMatchingSourceTextAsync
            // to return SourceText (tracked https://github.com/dotnet/roslyn/issues/64504). We might want do that 
            // for perf reasons to avoid transfering large strings OOP.
            return SourceText.From(text, defaultEncoding, checksumAlgorithm);
        }
 
        return await Task.Run(() => TryGetPdbMatchingSourceTextFromDisk(log, filePath, defaultEncoding, requiredChecksum, checksumAlgorithm), cancellationToken).ConfigureAwait(false);
    }
 
    private static DebugInformationReaderProvider? GetMethodDebugInfoReader(TraceLog log, CompilationOutputs compilationOutputs, string projectName)
    {
        DebugInformationReaderProvider? debugInfoReaderProvider;
        try
        {
            debugInfoReaderProvider = compilationOutputs.OpenPdb();
 
            if (debugInfoReaderProvider == null)
            {
                log.Write($"Source file of project '{projectName}' doesn't match output PDB: PDB '{compilationOutputs.PdbDisplayPath}' (assembly: '{compilationOutputs.AssemblyDisplayPath}') not found", LogMessageSeverity.Warning);
            }
 
            return debugInfoReaderProvider;
        }
        catch (Exception e)
        {
            log.Write($"Source file of project '{projectName}' doesn't match output PDB: error opening PDB '{compilationOutputs.PdbDisplayPath}' (assembly: '{compilationOutputs.AssemblyDisplayPath}'): {e.Message}", LogMessageSeverity.Warning);
            return null;
        }
    }
 
    public void CommitChanges(Solution solution, ImmutableDictionary<ProjectId, StaleProjectInfo> staleProjects)
    {
        lock (_guard)
        {
            _solution = solution;
 
            var oldStaleProjects = _staleProjects;
            _staleProjects = staleProjects;
 
            _documentState.RemoveAll(
                static (documentId, _, args) => args.oldStaleProjects.ContainsKey(documentId.ProjectId) && !args.staleProjects.ContainsKey(documentId.ProjectId),
                (oldStaleProjects, staleProjects));
        }
    }
 
    private static bool IsMatchingSourceText(SourceText sourceText, ImmutableArray<byte> requiredChecksum, SourceHashAlgorithm checksumAlgorithm)
        => checksumAlgorithm == sourceText.ChecksumAlgorithm && sourceText.GetChecksum().SequenceEqual(requiredChecksum);
 
    private static Optional<SourceText?> TryGetPdbMatchingSourceTextFromDisk(
        TraceLog log,
        string sourceFilePath,
        Encoding? defaultEncoding,
        ImmutableArray<byte> requiredChecksum,
        SourceHashAlgorithm checksumAlgorithm)
    {
        try
        {
            using var fileStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
 
            var sourceText = SourceText.From(fileStream, defaultEncoding, checksumAlgorithm);
 
            if (IsMatchingSourceText(sourceText, requiredChecksum, checksumAlgorithm))
            {
                return sourceText;
            }
 
            log.Write($"Checksum differs for source file '{sourceFilePath}'", LogMessageSeverity.Warning);
 
            // does not match:
            return null;
        }
        catch (Exception e)
        {
            log.Write($"Error calculating checksum for source file '{sourceFilePath}': '{e.Message}'", LogMessageSeverity.Error);
 
            // unable to determine:
            return default;
        }
    }
 
    private bool? TryReadSourceFileDebugInfo(Document document, Encoding? documentEncoding, out ImmutableArray<byte> checksum, out SourceHashAlgorithm checksumAlgorithm, out Encoding? defaultEncoding)
    {
        Contract.ThrowIfNull(document.FilePath);
        defaultEncoding = null;
 
        var compilationOutputs = debuggingSession.GetCompilationOutputs(document.Project);
        using var debugInfoReaderProvider = GetMethodDebugInfoReader(debuggingSession.SessionLog, compilationOutputs, document.Project.Name);
        if (debugInfoReaderProvider == null)
        {
            // unable to determine whether document is in the PDB
            checksum = default;
            checksumAlgorithm = default;
            return null;
        }
 
        var debugInfoReader = debugInfoReaderProvider.CreateEditAndContinueDebugInfoReader();
 
        var result = TryReadSourceFileChecksumFromPdb(debuggingSession.SessionLog, debugInfoReader, document.FilePath, out checksum, out checksumAlgorithm);
 
        if (result == true)
        {
            try
            {
                defaultEncoding = debugInfoReader.GetDefaultSourceFileEncoding();
            }
            catch (NotSupportedException e)
            {
                debuggingSession.SessionLog.Write($"Unable to determine default defaultEncoding for '{document.FilePath}': {e.Message}");
                defaultEncoding = documentEncoding;
            }
        }
 
        return result;
    }
 
    /// <summary>
    /// Returns true if the PDB contains a document record for given <paramref name="sourceFilePath"/>,
    /// in which case <paramref name="checksum"/> and <paramref name="algorithm"/> contain its checksum.
    /// False if the document is not found in the PDB.
    /// Null if it can't be determined because the PDB is not available or an error occurred while reading the PDB.
    /// </summary>
    private static bool? TryReadSourceFileChecksumFromPdb(
        TraceLog log,
        EditAndContinueDebugInfoReader debugInfoReader,
        string sourceFilePath,
        out ImmutableArray<byte> checksum,
        out SourceHashAlgorithm algorithm)
    {
        checksum = default;
        algorithm = default;
 
        try
        {
            if (!debugInfoReader.TryGetDocumentChecksum(sourceFilePath, out checksum, out var algorithmId))
            {
                log.Write($"Source '{sourceFilePath}' doesn't match output PDB: no document", LogMessageSeverity.Warning);
                return false;
            }
 
            algorithm = SourceHashAlgorithms.GetSourceHashAlgorithm(algorithmId);
            if (algorithm == SourceHashAlgorithm.None)
            {
                // This can only happen if the PDB was post-processed by a misbehaving tool.
                log.Write($"Source '{sourceFilePath}' doesn't match PDB: unknown checksum alg", LogMessageSeverity.Warning);
            }
 
            return true;
        }
        catch (Exception e)
        {
            log.Write($"Source '{sourceFilePath}' doesn't match output PDB: error reading symbols: {e.Message}", LogMessageSeverity.Error);
        }
 
        // unable to determine
        return null;
    }
}