|
// 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.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
{
private readonly DebuggingSession _debuggingSession;
private 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>
/// 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.
/// </summary>
private readonly Dictionary<DocumentId, DocumentState> _documentState = [];
private readonly object _guard = new();
public CommittedSolution(DebuggingSession debuggingSession, Solution solution, IEnumerable<KeyValuePair<DocumentId, DocumentState>> initialDocumentStates)
{
_solution = solution;
_debuggingSession = debuggingSession;
_documentState.AddRange(initialDocumentStates);
}
// 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 bool HasNoChanges(Solution solution)
=> _solution == solution;
public Project? GetProject(ProjectId id)
=> _solution.GetProject(id);
public Project GetRequiredProject(ProjectId id)
=> _solution.GetRequiredProject(id);
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(DocumentId documentId, Document? currentDocument, CancellationToken cancellationToken, bool reloadOutOfSyncDocument = false)
{
Contract.ThrowIfFalse(currentDocument == null || documentId == currentDocument.Id);
Solution solution;
var documentState = DocumentState.None;
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.SupportsEditAndContinue())
{
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
{
// 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 = TryReadSourceFileChecksumFromPdb(document, out var requiredChecksum, out var checksumAlgorithm);
var maybeMatchingSourceText = (maybePdbHasDocument == true) ?
await TryGetMatchingSourceTextAsync(sourceText, document.FilePath, currentDocument, _debuggingSession.SourceTextProvider, requiredChecksum, checksumAlgorithm, cancellationToken).ConfigureAwait(false) : default;
return (maybeMatchingSourceText, maybePdbHasDocument);
}
private static async ValueTask<Optional<SourceText?>> TryGetMatchingSourceTextAsync(
SourceText sourceText, string filePath, Document? currentDocument, IPdbMatchingSourceTextProvider sourceTextProvider, ImmutableArray<byte> requiredChecksum, SourceHashAlgorithm checksumAlgorithm, 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)
{
return SourceText.From(text, sourceText.Encoding, checksumAlgorithm);
}
return await Task.Run(() => TryGetPdbMatchingSourceTextFromDisk(filePath, sourceText.Encoding, requiredChecksum, checksumAlgorithm), cancellationToken).ConfigureAwait(false);
}
internal static async Task<IEnumerable<KeyValuePair<DocumentId, DocumentState>>> GetMatchingDocumentsAsync(
IEnumerable<(Project, IEnumerable<CodeAnalysis.DocumentState>)> documentsByProject,
Func<Project, CompilationOutputs> compilationOutputsProvider,
IPdbMatchingSourceTextProvider sourceTextProvider,
CancellationToken cancellationToken)
{
var projectTasks = documentsByProject.Select(async projectDocumentStates =>
{
cancellationToken.ThrowIfCancellationRequested();
var (project, documentStates) = projectDocumentStates;
// Skip projects that do not support Roslyn EnC (e.g. F#, etc).
// Source files of these may not even be captured in the solution snapshot.
if (!project.SupportsEditAndContinue())
{
return Array.Empty<DocumentId?>();
}
using var debugInfoReaderProvider = GetMethodDebugInfoReader(compilationOutputsProvider(project), project.Name);
if (debugInfoReaderProvider == null)
{
return Array.Empty<DocumentId?>();
}
var debugInfoReader = debugInfoReaderProvider.CreateEditAndContinueMethodDebugInfoReader();
var documentTasks = documentStates.Select(async documentState =>
{
cancellationToken.ThrowIfCancellationRequested();
if (documentState.SupportsEditAndContinue())
{
var sourceFilePath = documentState.FilePath;
Contract.ThrowIfNull(sourceFilePath);
// Hydrate the solution snapshot with the content of the file.
// It's important to do this before we start watching for changes so that we have a baseline we can compare future snapshots to.
var sourceText = await documentState.GetTextAsync(cancellationToken).ConfigureAwait(false);
// TODO: https://github.com/dotnet/roslyn/issues/51993
// avoid rereading the file in common case - the workspace should create source texts with the right checksum algorithm and encoding
if (TryReadSourceFileChecksumFromPdb(debugInfoReader, sourceFilePath, out var requiredChecksum, out var checksumAlgorithm) == true &&
await TryGetMatchingSourceTextAsync(sourceText, sourceFilePath, currentDocument: null, sourceTextProvider, requiredChecksum, checksumAlgorithm, cancellationToken).ConfigureAwait(false) is { HasValue: true, Value: not null })
{
return documentState.Id;
}
}
return null;
});
return await Task.WhenAll(documentTasks).ConfigureAwait(false);
});
var documentIdArrays = await Task.WhenAll(projectTasks).ConfigureAwait(false);
return documentIdArrays.SelectMany(ids => ids.WhereNotNull()).Select(id => KeyValuePairUtil.Create(id, DocumentState.MatchesBuildOutput));
}
private static DebugInformationReaderProvider? GetMethodDebugInfoReader(CompilationOutputs compilationOutputs, string projectName)
{
DebugInformationReaderProvider? debugInfoReaderProvider;
try
{
debugInfoReaderProvider = compilationOutputs.OpenPdb();
if (debugInfoReaderProvider == null)
{
EditAndContinueService.Log.Write("Source file of project '{0}' doesn't match output PDB: PDB '{1}' (assembly: '{2}') not found", projectName, compilationOutputs.PdbDisplayPath, compilationOutputs.AssemblyDisplayPath);
}
return debugInfoReaderProvider;
}
catch (Exception e)
{
EditAndContinueService.Log.Write("Source file of project '{0}' doesn't match output PDB: error opening PDB '{1}' (assembly: '{2}'): {3}", projectName, compilationOutputs.PdbDisplayPath, compilationOutputs.AssemblyDisplayPath, e.Message);
return null;
}
}
public void CommitSolution(Solution solution)
{
lock (_guard)
{
_solution = solution;
}
}
private static bool IsMatchingSourceText(SourceText sourceText, ImmutableArray<byte> requiredChecksum, SourceHashAlgorithm checksumAlgorithm)
=> checksumAlgorithm == sourceText.ChecksumAlgorithm && sourceText.GetChecksum().SequenceEqual(requiredChecksum);
private static Optional<SourceText?> TryGetPdbMatchingSourceTextFromDisk(string sourceFilePath, Encoding? encoding, ImmutableArray<byte> requiredChecksum, SourceHashAlgorithm checksumAlgorithm)
{
try
{
using var fileStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
// We must use the encoding of the document as determined by the IDE (the editor).
// This might differ from the encoding that the compiler chooses, so if we just relied on the compiler we
// might end up updating the committed solution with a document that has a different encoding than
// the one that's in the workspace, resulting in false document changes when we compare the two.
var sourceText = SourceText.From(fileStream, encoding, checksumAlgorithm);
if (IsMatchingSourceText(sourceText, requiredChecksum, checksumAlgorithm))
{
return sourceText;
}
EditAndContinueService.Log.Write("Checksum differs for source file '{0}'", sourceFilePath);
// does not match:
return null;
}
catch (Exception e)
{
EditAndContinueService.Log.Write("Error calculating checksum for source file '{0}': '{1}'", sourceFilePath, e.Message);
// unable to determine:
return default;
}
}
private bool? TryReadSourceFileChecksumFromPdb(Document document, out ImmutableArray<byte> requiredChecksum, out SourceHashAlgorithm checksumAlgorithm)
{
Contract.ThrowIfNull(document.FilePath);
var compilationOutputs = _debuggingSession.GetCompilationOutputs(document.Project);
using var debugInfoReaderProvider = GetMethodDebugInfoReader(compilationOutputs, document.Project.Name);
if (debugInfoReaderProvider == null)
{
// unable to determine whether document is in the PDB
requiredChecksum = default;
checksumAlgorithm = default;
return null;
}
var debugInfoReader = debugInfoReaderProvider.CreateEditAndContinueMethodDebugInfoReader();
return TryReadSourceFileChecksumFromPdb(debugInfoReader, document.FilePath, out requiredChecksum, out checksumAlgorithm);
}
/// <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(EditAndContinueMethodDebugInfoReader debugInfoReader, string sourceFilePath, out ImmutableArray<byte> checksum, out SourceHashAlgorithm algorithm)
{
checksum = default;
algorithm = default;
try
{
if (!debugInfoReader.TryGetDocumentChecksum(sourceFilePath, out checksum, out var algorithmId))
{
EditAndContinueService.Log.Write("Source '{0}' doesn't match output PDB: no document", sourceFilePath);
return false;
}
algorithm = SourceHashAlgorithms.GetSourceHashAlgorithm(algorithmId);
if (algorithm == SourceHashAlgorithm.None)
{
// This can only happen if the PDB was post-processed by a misbehaving tool.
EditAndContinueService.Log.Write("Source '{0}' doesn't match PDB: unknown checksum alg", sourceFilePath);
}
return true;
}
catch (Exception e)
{
EditAndContinueService.Log.Write("Source '{0}' doesn't match output PDB: error reading symbols: {1}", sourceFilePath, e.Message);
}
// unable to determine
return null;
}
}
|