Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public class DistributedApplicationBuilder : IDistributedApplicationBuilder
private readonly DistributedApplicationOptions _options;
private readonly HostApplicationBuilder _innerBuilder;
private readonly IUserSecretsManager _userSecretsManager;
private readonly IFileSystemService _directoryService;
private readonly FileSystemService _directoryService;

/// <inheritdoc />
public IHostEnvironment Environment => _innerBuilder.Environment;
Expand Down Expand Up @@ -303,8 +303,12 @@ public DistributedApplicationBuilder(DistributedApplicationOptions options)

// Core things
// Create and register the directory service (first, so it can be used by other services)
_directoryService = new FileSystemService();
_innerBuilder.Services.AddSingleton<IFileSystemService>(_directoryService);
_directoryService = new FileSystemService(_innerBuilder.Configuration);
_innerBuilder.Services.AddSingleton<IFileSystemService>(sp =>
{
_directoryService.SetLogger(sp.GetRequiredService<ILogger<FileSystemService>>());
return _directoryService;
});

// Create and register the user secrets manager
var userSecretsFactory = new UserSecretsManagerFactory(_directoryService);
Expand Down
180 changes: 173 additions & 7 deletions src/Aspire.Hosting/Utils/FileSystemService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,146 @@

#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only

using System.Collections.Concurrent;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace Aspire.Hosting;

/// <summary>
/// Default implementation of <see cref="IFileSystemService"/>.
/// </summary>
internal sealed class FileSystemService : IFileSystemService
internal sealed class FileSystemService : IFileSystemService, IDisposable
{
private readonly TempFileSystemService _tempDirectory = new();
private readonly TempFileSystemService _tempDirectory;
private ILogger? _logger;
private readonly bool _preserveTempFiles;

// Track allocated temp files and directories as disposable objects using path as key
private readonly ConcurrentDictionary<string, IDisposable> _allocatedItems = new();

public FileSystemService(IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);

// Check configuration to preserve temp files for debugging
_preserveTempFiles = configuration["ASPIRE_PRESERVE_TEMP_FILES"] is not null;

_tempDirectory = new TempFileSystemService(this);
}

/// <summary>
/// Sets the logger for this service. Called after service provider is built.
/// </summary>
/// <remarks>
/// The logger cannot be injected via constructor because the FileSystemService
/// is allocated before logging is fully initialized in the DistributedApplicationBuilder.
/// </remarks>
internal void SetLogger(ILogger<FileSystemService> logger)
{
_logger = logger;
}

/// <inheritdoc/>
public ITempFileSystemService TempDirectory => _tempDirectory;

/// <summary>
/// Gets whether temporary files should be preserved for debugging.
/// </summary>
internal bool ShouldPreserveTempFiles => _preserveTempFiles;

/// <summary>
/// Gets the logger for this service, if set.
/// </summary>
internal ILogger? Logger => _logger;

private bool _disposed;
private readonly object _disposeLock = new();

/// <summary>
/// Tracks a temporary item for cleanup on service disposal.
/// </summary>
internal void TrackItem(string path, IDisposable item)
{
lock (_disposeLock)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(FileSystemService), "Cannot allocate temporary files after the service has been disposed.");
}

_allocatedItems.TryAdd(path, item);
}
}

/// <summary>
/// Removes a temporary item from tracking.
/// </summary>
internal void UntrackItem(string path)
{
_allocatedItems.TryRemove(path, out _);
}

/// <summary>
/// Cleans up any remaining temporary files and directories.
/// </summary>
public void Dispose()
{
lock (_disposeLock)
{
if (_disposed)
{
return;
}

_disposed = true;
}

if (_preserveTempFiles)
{
_logger?.LogInformation("Skipping cleanup of {Count} temporary files/directories due to ASPIRE_PRESERVE_TEMP_FILES configuration", _allocatedItems.Count);
return;
}

if (_allocatedItems.IsEmpty)
{
return;
}

_logger?.LogDebug("Cleaning up {Count} remaining temporary files/directories", _allocatedItems.Count);

foreach (var kvp in _allocatedItems)
{
try
{
kvp.Value.Dispose();
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to clean up temporary item");
}
}
}

/// <summary>
/// Implementation of <see cref="ITempFileSystemService"/>.
/// </summary>
private sealed class TempFileSystemService : ITempFileSystemService
{
private readonly FileSystemService _parent;

public TempFileSystemService(FileSystemService parent)
{
_parent = parent;
}

/// <inheritdoc/>
public TempDirectory CreateTempSubdirectory(string? prefix = null)
{
var path = Directory.CreateTempSubdirectory(prefix ?? "aspire").FullName;
return new DefaultTempDirectory(path);
var tempDir = new DefaultTempDirectory(path, _parent);
_parent.TrackItem(path, tempDir);
return tempDir;
}

/// <inheritdoc/>
Expand All @@ -33,14 +151,18 @@ public TempFile CreateTempFile(string? fileName = null)
if (fileName is null)
{
var tempFile = Path.GetTempFileName();
return new DefaultTempFile(tempFile, deleteParentDirectory: false);
var file = new DefaultTempFile(tempFile, deleteParentDirectory: false, _parent);
_parent.TrackItem(tempFile, file);
return file;
}

// Create a temp subdirectory and place the named file inside it
var tempDir = Directory.CreateTempSubdirectory("aspire").FullName;
var filePath = Path.Combine(tempDir, fileName);
File.Create(filePath).Dispose();
return new DefaultTempFile(filePath, deleteParentDirectory: true);
var tempFileObj = new DefaultTempFile(filePath, deleteParentDirectory: true, _parent);
_parent.TrackItem(filePath, tempFileObj);
return tempFileObj;
}
}

Expand All @@ -50,21 +172,43 @@ public TempFile CreateTempFile(string? fileName = null)
private sealed class DefaultTempDirectory : TempDirectory
{
private readonly string _path;
private readonly FileSystemService _parent;
private bool _disposed;

public DefaultTempDirectory(string path)
public DefaultTempDirectory(string path, FileSystemService parent)
{
_path = path;
_parent = parent;

_parent.Logger?.LogDebug("Allocated temporary directory: {Path}", path);
}

public override string Path => _path;

public override void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;

// Remove from tracking
_parent.UntrackItem(_path);

// Skip deletion if preserve flag is set
if (_parent.ShouldPreserveTempFiles)
{
return;
}

try
{
if (Directory.Exists(_path))
{
Directory.Delete(_path, recursive: true);
_parent.Logger?.LogDebug("Cleaned up temporary directory: {Path}", _path);
}
}
catch
Expand All @@ -81,22 +225,44 @@ private sealed class DefaultTempFile : TempFile
{
private readonly string _path;
private readonly bool _deleteParentDirectory;
private readonly FileSystemService _parent;
private bool _disposed;

public DefaultTempFile(string path, bool deleteParentDirectory)
public DefaultTempFile(string path, bool deleteParentDirectory, FileSystemService parent)
{
_path = path;
_deleteParentDirectory = deleteParentDirectory;
_parent = parent;

_parent.Logger?.LogDebug("Allocated temporary file: {Path}", path);
}

public override string Path => _path;

public override void Dispose()
{
if (_disposed)
{
return;
}

_disposed = true;

// Remove from tracking
_parent.UntrackItem(_path);

// Skip deletion if preserve flag is set
if (_parent.ShouldPreserveTempFiles)
{
return;
}

try
{
if (File.Exists(_path))
{
File.Delete(_path);
_parent.Logger?.LogDebug("Cleaned up temporary file: {Path}", _path);
}

if (_deleteParentDirectory)
Expand Down
5 changes: 4 additions & 1 deletion tests/Aspire.Hosting.Tests/AspireStoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Aspire.Hosting.Tests;
Expand Down Expand Up @@ -119,11 +120,13 @@ public void GetOrCreateFileWithContent_ShouldNotRecreateFile()
[InlineData(null)]
[InlineData("")]
[InlineData("./folder")]
[InlineData(".\\folder")]
[InlineData("folder")]
[InlineData("obj/")]
[InlineData("obj\\")]
public void AspireStoreConstructor_ShouldThrow_IfNotAbsolutePath(string? basePath)
{
var directoryService = new FileSystemService();
var directoryService = new FileSystemService(new ConfigurationBuilder().Build());
Assert.ThrowsAny<Exception>(() => new AspireStore(basePath!, directoryService));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ private static DashboardEventHandlers CreateHook(
new TestHostApplicationLifetime(),
new Hosting.Eventing.DistributedApplicationEventing(),
rewriter,
new FileSystemService()
new FileSystemService(configuration)
);
}

Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2086,7 +2086,7 @@ private static DcpExecutor CreateAppExecutor(
new TestDcpDependencyCheckService(),
new DcpNameGenerator(configuration, Options.Create(dcpOptions)),
events ?? new DcpExecutorEvents(),
new Locations(new FileSystemService()),
new Locations(new FileSystemService(configuration ?? new ConfigurationBuilder().Build())),
developerCertificateService);
#pragma warning restore ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Resources;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
Expand All @@ -18,7 +19,7 @@ public sealed class DcpHostNotificationTests
{
private static Locations CreateTestLocations()
{
var directoryService = new FileSystemService();
var directoryService = new FileSystemService(new ConfigurationBuilder().Build());
return new Locations(directoryService);
}

Expand Down
Loading