// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
#if !WINDOWS_UWP && !WIN_UI
using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;
///
/// Cleanup and retention policy of the per-test temporary directory of
/// .
///
internal sealed partial class TestContextImplementation
{
///
/// Environment variable that, when set to a truthy value, retains every per-test temporary
/// directory (including those of passing tests) instead of deleting them. Used for debugging.
///
private const string RetainTestTempDirectoryEnvironmentVariable = "MSTEST_TEST_TEMP_DIRECTORY_RETAIN";
///
/// Deletes the per-test temporary directory unless the test failed or retention was requested.
/// Best-effort: it swallows all exceptions, so a passing test cannot be failed by a cleanup
/// error. It runs after the test has completed, so it cannot extend the test's own execution or
/// trip its timeout; note that the delete is synchronous, so exception swallowing is guaranteed
/// but bounded cleanup time is not (a pathologically stalled filesystem could make disposal
/// itself slow).
///
private void CleanupTestTempDirectory()
{
// Read the lazy-init state under the same lock the getter writes it under. Without this
// acquire barrier, a directory created on a worker thread the test spawned (but did not
// join) could be observed as not-yet-created here, silently skipping cleanup and leaking
// the directory even on a passing test.
string? directory;
lock (_testTempDirectoryLock)
{
// Mark cleanup as started while holding the lock so a concurrent first getter that has
// not yet created the directory will see this and skip creation, instead of creating a
// directory after cleanup has already run (which would leak it).
_testTempDirectoryCleanupStarted = true;
if (!_testTempDirectoryCreated || _testTempDirectory is not { Length: > 0 } createdDirectory)
{
return;
}
directory = createdDirectory;
}
if (ShouldRetainTestTempDirectory())
{
return;
}
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch (Exception ex)
{
// A leaked file handle, or a transient antivirus/indexer lock on Windows, can make the
// delete fail. This must never fail an otherwise passing test, so we swallow it and only
// surface the failure through the diagnostic trace log. The logging itself is guarded so
// that a misbehaving trace logger cannot let an exception escape Dispose either.
try
{
PlatformServiceProvider.Instance.AdapterTraceLogger.Warning(
"Failed to delete per-test temporary directory '{0}': {1}", directory, ex);
}
catch (Exception)
{
// Intentionally ignored: cleanup is best-effort and must never throw from Dispose.
}
}
}
///
/// Determines whether the per-test temporary directory should be kept: retained on any
/// non-passing outcome, when the test registered a result file living inside it, or when
/// retention is forced via the environment variable escape hatch.
///
private bool ShouldRetainTestTempDirectory()
{
// Retain a failed (or otherwise non-passing) test's artifacts for inspection.
if (_outcome != UnitTestOutcome.Passed)
{
return true;
}
// If the test registered a result file (via AddResultFile) that lives inside the temp
// directory, or an earlier retry attached assertion diagnostics there, that file is
// collected by the test host *after* this context is disposed. Deleting the directory now
// would leave the attachment pointing at a missing file, so retain it in that case.
if (_hasResultFileUnderTestTempDirectory || Volatile.Read(ref _retainAssertionFailureDiagnosticsDirectory) != 0)
{
return true;
}
string? retain;
try
{
retain = Environment.GetEnvironmentVariable(RetainTestTempDirectoryEnvironmentVariable);
}
catch (System.Security.SecurityException)
{
// Environment access is restricted (possible on .NET Framework). Treat the retention
// override as unset so cleanup does not throw out of Dispose.
return false;
}
return retain is "1" || string.Equals(retain, "true", StringComparison.OrdinalIgnoreCase);
}
///
/// Returns whether is located inside .
///
private static bool IsPathUnderDirectory(string filePath, string directory)
{
string normalizedDirectory = Path.GetFullPath(directory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
string normalizedFile = Path.GetFullPath(filePath);
return normalizedFile.StartsWith(
normalizedDirectory,
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
///
/// Returns whether any currently-registered result file lives inside the per-test temporary
/// directory.
///
private bool HasResultFileUnderTestTempDirectory()
{
if (_testResultFiles is not { Count: > 0 } files
|| !Volatile.Read(ref _testTempDirectoryCreated)
|| _testTempDirectory is not { Length: > 0 } tempDir)
{
return false;
}
foreach (string file in files)
{
if (IsPathUnderDirectory(file, tempDir))
{
return true;
}
}
return false;
}
}
#endif