|
// 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.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Roslyn.Utilities;
using System.Collections.Immutable;
using System.Reflection;
using System.Globalization;
using System.Diagnostics;
namespace Microsoft.CodeAnalysis
{
internal sealed class ShadowCopyAnalyzerPathResolver : IAnalyzerPathResolver
{
/// <summary>
/// The base directory for shadow copies. Each instance of
/// <see cref="ShadowCopyAnalyzerPathResolver"/> gets its own
/// subdirectory under this directory. This is also the starting point
/// for scavenge operations.
/// </summary>
internal string BaseDirectory { get; }
internal string ShadowDirectory { get; }
/// <summary>
/// As long as this mutex is alive, other instances of this type will not try to clean
/// up the shadow directory.
/// </summary>
private Mutex Mutex { get; }
internal Task DeleteLeftoverDirectoriesTask { get; }
/// <summary>
/// This is a counter that is incremented each time a new shadow sub directory is created to ensure they
/// have unique names.
/// </summary>
private int _directoryCount;
/// <summary>
/// This is a map from the original directory name to the numbered directory name it
/// occupies in the shadow directory.
/// </summary>
private ConcurrentDictionary<string, int> OriginalDirectoryMap { get; } = new(AnalyzerAssemblyLoader.OriginalPathComparer);
/// <summary>
/// This interface can be called from multiple threads for the same original assembly path. This
/// is a map between the original path and the Task that completes when the shadow copy for that
/// original path completes.
/// </summary>
private ConcurrentDictionary<string, Task<string>> CopyMap { get; } = new(AnalyzerAssemblyLoader.OriginalPathComparer);
/// <summary>
/// This is the number of shadow copies that have occurred in this instance.
/// </summary>
/// <remarks>
/// This is used for testing, it should not be used for any other purpose.
/// </remarks>
internal int CopyCount => CopyMap.Count;
public ShadowCopyAnalyzerPathResolver(string baseDirectory)
{
if (baseDirectory is null)
{
throw new ArgumentNullException(nameof(baseDirectory));
}
// The shadow copy analyzer should only be created on Windows. To create on Linux we cannot use
// GetTempPath as it's not per-user. Generally there is no need as LoadFromStream achieves the same
// effect
if (!Path.IsPathRooted(baseDirectory))
{
throw new ArgumentException($"Must be a full path: {baseDirectory}", nameof(baseDirectory));
}
BaseDirectory = baseDirectory;
var shadowDirectoryName = Guid.NewGuid().ToString("N").ToLowerInvariant();
// The directory is deliberately _not_ created at this point. It will only be created when the first
// request comes in. This avoids creating unnecessary directories when no analyzers are loaded
// via the shadow layer.
ShadowDirectory = Path.Combine(BaseDirectory, shadowDirectoryName);
Mutex = new Mutex(initiallyOwned: false, name: shadowDirectoryName);
DeleteLeftoverDirectoriesTask = Task.Run(DeleteLeftoverDirectories);
}
private void DeleteLeftoverDirectories()
{
// Avoid first chance exception
if (!Directory.Exists(BaseDirectory))
return;
IEnumerable<string> subDirectories;
try
{
subDirectories = Directory.EnumerateDirectories(BaseDirectory);
}
catch (DirectoryNotFoundException)
{
return;
}
foreach (var subDirectory in subDirectories)
{
string name = Path.GetFileName(subDirectory).ToLowerInvariant();
Mutex? mutex = null;
try
{
// We only want to try deleting the directory if no-one else is currently
// using it. That is, if there is no corresponding mutex.
if (!Mutex.TryOpenExisting(name, out mutex))
{
try
{
// Avoid calling ClearReadOnlyFlagOnFiles before calling Directory.Delete. In general, files
// created by the shadow copy should not be marked read-only (CopyFile also clears the
// read-only flag), and clearing the read-only flag for the entire directory requires
// significant disk access.
//
// If the deletion fails for an IOException, it may have been the result of a file being
// marked read-only. We catch that exception and perform an explicit clear before trying
// again.
Directory.Delete(subDirectory, recursive: true);
}
catch (IOException)
{
// Retry after clearing the read-only flag
ClearReadOnlyFlagOnFiles(subDirectory);
Directory.Delete(subDirectory, recursive: true);
}
}
}
catch
{
// If something goes wrong we will leave it to the next run to clean up.
// Just swallow the exception and move on.
}
finally
{
if (mutex != null)
{
mutex.Dispose();
}
}
}
}
public bool IsAnalyzerPathHandled(string analyzerFilePath) => true;
public string GetResolvedAnalyzerPath(string originalAnalyzerPath)
{
var analyzerShadowDir = GetAnalyzerShadowDirectory(originalAnalyzerPath);
var analyzerShadowPath = Path.Combine(analyzerShadowDir, Path.GetFileName(originalAnalyzerPath));
ShadowCopyFile(originalAnalyzerPath, analyzerShadowPath);
return analyzerShadowPath;
}
public string? GetResolvedSatellitePath(string originalAnalyzerPath, CultureInfo cultureInfo)
{
var satelliteFilePath = AnalyzerAssemblyLoader.GetSatelliteAssemblyPath(originalAnalyzerPath, cultureInfo);
if (satelliteFilePath is null)
{
return null;
}
var analyzerShadowDir = GetAnalyzerShadowDirectory(originalAnalyzerPath);
var satelliteFileName = Path.GetFileName(satelliteFilePath);
var satelliteDirectoryName = Path.GetFileName(Path.GetDirectoryName(satelliteFilePath));
var shadowSatellitePath = Path.Combine(analyzerShadowDir, satelliteDirectoryName!, satelliteFileName);
ShadowCopyFile(satelliteFilePath, shadowSatellitePath);
return shadowSatellitePath;
}
/// <summary>
/// Get the shadow directory for the given original analyzer file path.
/// </summary>
private string GetAnalyzerShadowDirectory(string analyzerFilePath)
{
var originalDirName = Path.GetDirectoryName(analyzerFilePath)!;
var shadowDirName = OriginalDirectoryMap.GetOrAdd(originalDirName, _ => Interlocked.Increment(ref _directoryCount)).ToString();
return Path.Combine(ShadowDirectory, shadowDirName);
}
/// <summary>
/// This type has to account for multiple threads calling into the various resolver APIs. To avoid two threads
/// writing at the same time this method is used to ensure only one thread _wins_ and both can wait for
/// that thread to complete the copy.
/// </summary>
private void ShadowCopyFile(string originalFilePath, string shadowCopyPath)
{
if (CopyMap.TryGetValue(originalFilePath, out var copyTask))
{
copyTask.Wait();
return;
}
var tcs = new TaskCompletionSource<string>();
var task = CopyMap.GetOrAdd(originalFilePath, tcs.Task);
if (object.ReferenceEquals(task, tcs.Task))
{
// This thread won and we need to do the copy.
try
{
copyFile(originalFilePath, shadowCopyPath);
tcs.SetResult(shadowCopyPath);
}
catch (Exception ex)
{
tcs.SetException(ex);
throw;
}
}
else
{
// This thread lost and we need to wait for the winner to finish the copy.
task.Wait();
Debug.Assert(AnalyzerAssemblyLoader.GeneratedPathComparer.Equals(shadowCopyPath, task.Result));
}
static void copyFile(string originalPath, string shadowCopyPath)
{
var directory = Path.GetDirectoryName(shadowCopyPath);
if (directory is null)
{
throw new ArgumentException($"Shadow copy path '{shadowCopyPath}' must not be the root directory");
}
_ = Directory.CreateDirectory(directory);
// The shadow copy should only copy files that exist. For files that don't exist, this best
// emulates not having the shadow copy layer
if (File.Exists(originalPath))
{
File.Copy(originalPath, shadowCopyPath);
ClearReadOnlyFlagOnFile(new FileInfo(shadowCopyPath));
}
}
}
private static void ClearReadOnlyFlagOnFiles(string directoryPath)
{
DirectoryInfo directory = new DirectoryInfo(directoryPath);
foreach (var file in directory.EnumerateFiles(searchPattern: "*", searchOption: SearchOption.AllDirectories))
{
ClearReadOnlyFlagOnFile(file);
}
}
private static void ClearReadOnlyFlagOnFile(FileInfo fileInfo)
{
try
{
if (fileInfo.IsReadOnly)
{
fileInfo.IsReadOnly = false;
}
}
catch
{
// There are many reasons this could fail. Ignore it and keep going.
}
}
}
}
|