Skip to content

Automatically cleanup old artifacts of file-based apps #49666

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 5 additions & 2 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,12 @@ The subdirectory is created by the SDK CLI with permissions restricting access t
Note that it is possible for multiple users to run the same file-based program, however each user's run uses different build artifacts since the base directory is unique per user.
Apart from keeping the source directory clean, such artifact isolation also avoids clashes of build outputs that are not project-scoped, like `project.assets.json`, in the case of multiple entry-point files.

Artifacts are cleaned periodically by a background task that is started by `dotnet run` and
removes current user's `dotnet run` build outputs that haven't been used in some time.
Artifacts are cleaned periodically (every 2 days) by a background task that is started by `dotnet run` and
removes current user's `dotnet run` build outputs that haven't been used in 30 days.
They are not cleaned immediately because they can be re-used on subsequent runs for better performance.
The automatic cleanup can be disabled by environment variable `DOTNET_CLI_DISABLE_FILE_BASED_APP_ARTIFACTS_AUTOMATIC_CLEANUP=true`,
but other parameters of the automatic cleanup are currently not configurable.
The same cleanup can be performed manually via command `dotnet clean-file-based-app-artifacts`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts on making this an option (or sub-command?) to the existing dotnet clean command instead? e.g. dotnet clean --file-based-app-artifacts or dotnet clean caches or some such @baronfel

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dotnet clean --file-based-apps or clean-file-based-apps seems good to me. The word artifacts in the context of clean seems redundant.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So does dotnet clean --file-based-apps sound good to everyone before I go and change to that?

FWIW, I chose to make this a separate command (currently hidden from dotnet --help) because it's fundamentally different from dotnet clean which uses MSBuild /t:Clean whereas dotnet clean-file-based-app-artifacts implements its own logic which also means no flags of the normal dotnet clean apply. That being said, I'm okay with having this as a flag for dotnet clean, but I think we might need to check there are no other flags passed.

As for dropping "artifacts" from the command name, dotnet clean-file-based-apps sounds a bit like your file-based apps will be deleted (not just their artifacts) but on the other hand I also dislike how unwieldy my current command name is.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to hear from @baronfel. I'm curious whether we could make this a sub-command instead, so something like dotnet clean file-based-apps. We could still make it hidden but more importantly we'd need to ensure it's not breaking in any way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd very much love a hidden subcommand. It's kinda a gnarly name to have a dedicated option flag, and it's really an implementation detail that we just don't have a super-great home for.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with a subcommand is that it also inherits all arguments and options from the parent command. But perhaps that's acceptable given it's a hidden subcommand.

Comment on lines +190 to +192
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@baronfel please take a look at these new entry point / env names.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've already discussed the subcommand in another thread above, but yeah, please also take a look at the env var name DOTNET_CLI_DISABLE_FILE_BASED_APP_ARTIFACTS_AUTOMATIC_CLEANUP.

Copy link
Member

@baronfel baronfel Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you take a look at the proposed unified/structured config model in https://github.com/dotnet/sdk/pull/49761/files#diff-dd9e3f58690e283b389a68dc9f7bf2923a8bec9a198e9a44da1edb549cca7e0bR11 and see where you think the relevant flag/toggle might live? I'm trying to create a unified config model based on IConfiguration from our current crazy explosion of knobs, and using that to try to inform the structure of what our actual desired configuration models might be.

To me, this feels like one of two things:

  • a knob that exists under a section related to the behaviors of clean
  • a knob that exists under a section related to the behaviors of file-based apps as a whole
[file_based_apps]
clean = "auto"

vs

[clean]
cleanup_file_based_apps = "auto"

or similar. cc @DamianEdwards for thoughts here too.

Copy link
Member Author

@jjonescz jjonescz Jul 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to have more automatic cleanups in the future as well (not just for file-based apps), so perhaps something like clean.automatic.file_based_apps = "on" / "off" to be future proof?


## Directives for project metadata

Expand Down
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Commands/Clean/CleanCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat
NoBuild = false,
NoRestore = true,
NoCache = true,
NoBuildMarkers = true,
NoWriteBuildMarkers = true,
},
static (msbuildArgs, msbuildPath) => new CleanCommand(msbuildArgs, msbuildPath),
[ CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, CleanCommandParser.TargetOption ],
Expand Down
2 changes: 2 additions & 0 deletions src/Cli/dotnet/Commands/Clean/CleanCommandParser.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 System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
using Microsoft.DotNet.Cli.Extensions;

namespace Microsoft.DotNet.Cli.Commands.Clean;
Expand Down Expand Up @@ -59,6 +60,7 @@ private static Command ConstructCommand()
command.Options.Add(NoLogoOption);
command.Options.Add(CommonOptions.DisableBuildServersOption);
command.Options.Add(TargetOption);
command.Subcommands.Add(CleanFileBasedAppArtifactsCommandParser.Command);

command.SetAction(CleanCommand.Run);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Cli.Utils.Extensions;

namespace Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;

internal sealed class CleanFileBasedAppArtifactsCommand(ParseResult parseResult) : CommandBase(parseResult)
{
public override int Execute()
{
bool dryRun = _parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.DryRunOption);

using var metadataFileStream = OpenMetadataFile();

bool anyErrors = false;
int count = 0;

foreach (var folder in GetFoldersToRemove())
{
if (dryRun)
{
Reporter.Verbose.WriteLine($"Would remove folder: {folder.FullName}");
count++;
}
else
{
try
{
folder.Delete(recursive: true);
Reporter.Verbose.WriteLine($"Removed folder: {folder.FullName}");
count++;
}
catch (Exception ex)
{
Reporter.Error.WriteLine(string.Format(CliCommandStrings.CleanFileBasedAppArtifactsErrorRemovingFolder, folder, ex.Message).Red());
anyErrors = true;
}
}
}

Reporter.Output.WriteLine(
dryRun
? CliCommandStrings.CleanFileBasedAppArtifactsWouldRemoveFolders
: CliCommandStrings.CleanFileBasedAppArtifactsTotalFoldersRemoved,
count);

if (!dryRun)
{
UpdateMetadata(metadataFileStream);
}

return anyErrors ? 1 : 0;
}

private IEnumerable<DirectoryInfo> GetFoldersToRemove()
{
var directory = new DirectoryInfo(VirtualProjectBuildingCommand.GetTempSubdirectory());

if (!directory.Exists)
{
Reporter.Error.WriteLine(string.Format(CliCommandStrings.CleanFileBasedAppArtifactsDirectoryNotFound, directory.FullName).Yellow());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why report a warning here? Won't that mean that installing the CLI then running the command produces a warning? I intuitively expect it would just return nothing because there was nothing to delete and it successfully deleted nothing 😄

Copy link
Member Author

@jjonescz jjonescz Jul 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thinking is that if the directory doesn't exist, something might be wrong (e.g., you have some env var set which changes where the directory points to) and it might be good to alert the user.

yield break;
}

Reporter.Output.WriteLine(CliCommandStrings.CleanFileBasedAppArtifactsScanning, directory.FullName);

var days = _parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.DaysOption);
var cutoff = DateTime.UtcNow.AddDays(-days);

foreach (var subdir in directory.GetDirectories())
{
if (subdir.LastWriteTimeUtc < cutoff)
{
yield return subdir;
}
}
}

private static FileInfo GetMetadataFile()
{
return new FileInfo(VirtualProjectBuildingCommand.GetTempSubpath(RunFileArtifactsMetadata.FilePath));
}

private FileStream? OpenMetadataFile()
{
if (!_parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.AutomaticOption))
{
return null;
}

// Open and lock the metadata file to ensure we are the only automatic cleanup process.
return GetMetadataFile().Open(FileMode.Create, FileAccess.ReadWrite, FileShare.None);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of concurrent calls to this method there will be an exception generated here. How does that manifest in terms of user experience?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only called during the automatic cleanup which runs in the background and its output is not visible anywhere, so the exception won't be seen.

}

private static void UpdateMetadata(FileStream? metadataFileStream)
{
if (metadataFileStream is null)
{
return;
}

var metadata = new RunFileArtifactsMetadata
{
LastAutomaticCleanupUtc = DateTime.UtcNow,
};
JsonSerializer.Serialize(metadataFileStream, metadata, RunFileJsonSerializerContext.Default.RunFileArtifactsMetadata);
}

/// <summary>
/// Starts a background process to clean up file-based app artifacts if needed.
/// </summary>
public static void StartAutomaticCleanupIfNeeded()
{
if (ShouldStartAutomaticCleanup())
{
Reporter.Verbose.WriteLine("Starting automatic cleanup of file-based app artifacts.");

var startInfo = new ProcessStartInfo
{
FileName = new Muxer().MuxerPath,
Arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(
[
CleanCommandParser.GetCommand().Name,
CleanFileBasedAppArtifactsCommandParser.Command.Name,
CleanFileBasedAppArtifactsCommandParser.AutomaticOption.Name,
]),
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};

Process.Start(startInfo);
}
}

private static bool ShouldStartAutomaticCleanup()
{
if (Env.GetEnvironmentVariableAsBool("DOTNET_CLI_DISABLE_FILE_BASED_APP_ARTIFACTS_AUTOMATIC_CLEANUP", defaultValue: false))
{
return false;
}

FileInfo? metadataFile = null;
try
{
metadataFile = GetMetadataFile();

if (!metadataFile.Exists)
{
return true;
}

using var stream = metadataFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
var metadata = JsonSerializer.Deserialize(stream, RunFileJsonSerializerContext.Default.RunFileArtifactsMetadata);

if (metadata?.LastAutomaticCleanupUtc is not { } timestamp)
{
return true;
}

// Start automatic cleanup every two days.
return timestamp.AddDays(2) < DateTime.UtcNow;
}
catch (Exception ex)
{
Reporter.Verbose.WriteLine($"Cannot access artifacts metadata file '{metadataFile?.FullName}': {ex}");

// If the file cannot be accessed, automatic cleanup might already be running.
return false;
}
}
}

/// <summary>
/// Metadata stored at the root level of the file-based app artifacts directory.
/// </summary>
internal sealed class RunFileArtifactsMetadata
{
public const string FilePath = "dotnet-run-file-artifacts-metadata.json";

public DateTime? LastAutomaticCleanupUtc { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;

namespace Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;

internal sealed class CleanFileBasedAppArtifactsCommandParser
{
public static readonly Option<bool> DryRunOption = new("--dry-run")
{
Description = CliCommandStrings.CleanFileBasedAppArtifactsDryRun,
Arity = ArgumentArity.Zero,
};

public static readonly Option<int> DaysOption = new("--days")
{
Description = CliCommandStrings.CleanFileBasedAppArtifactsDays,
DefaultValueFactory = _ => 30,
};

/// <summary>
/// Specified internally when the command is started automatically in background by <c>dotnet run</c>.
/// Causes <see cref="RunFileArtifactsMetadata.LastAutomaticCleanupUtc"/> to be updated.
/// </summary>
public static readonly Option<bool> AutomaticOption = new("--automatic")
{
Hidden = true,
};

public static Command Command => field ??= ConstructCommand();

private static Command ConstructCommand()
{
Command command = new("file-based-apps", CliCommandStrings.CleanFileBasedAppArtifactsCommandDescription)
{
Hidden = true,
Options =
{
DryRunOption,
DaysOption,
AutomaticOption,
},
};

command.SetAction((parseResult) => new CleanFileBasedAppArtifactsCommand(parseResult).Execute());
return command;
}
}
29 changes: 29 additions & 0 deletions src/Cli/dotnet/Commands/CliCommandStrings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,35 @@ Paths searched: '{1}', '{2}'.</value>
<data name="CleanRuntimeOptionDescription" xml:space="preserve">
<value>The target runtime to clean for.</value>
</data>
<data name="CleanFileBasedAppArtifactsCommandDescription" xml:space="preserve">
<value>Removes artifacts created for file-based apps</value>
</data>
<data name="CleanFileBasedAppArtifactsDryRun" xml:space="preserve">
<value>Determines changes without actually modifying the file system</value>
</data>
<data name="CleanFileBasedAppArtifactsDays" xml:space="preserve">
<value>How many days an artifact folder needs to be unused in order to be removed</value>
</data>
<data name="CleanFileBasedAppArtifactsErrorRemovingFolder" xml:space="preserve">
<value>Error removing folder '{0}': {1}</value>
<comment>{0} is folder path. {1} is inner error message.</comment>
</data>
<data name="CleanFileBasedAppArtifactsWouldRemoveFolders" xml:space="preserve">
<value>Would remove folders: {0}</value>
<comment>{0} is count.</comment>
</data>
<data name="CleanFileBasedAppArtifactsTotalFoldersRemoved" xml:space="preserve">
<value>Total folders removed: {0}</value>
<comment>{0} is count.</comment>
</data>
<data name="CleanFileBasedAppArtifactsDirectoryNotFound" xml:space="preserve">
<value>Warning: Artifacts directory does not exist: {0}</value>
<comment>{0} is directory path.</comment>
</data>
<data name="CleanFileBasedAppArtifactsScanning" xml:space="preserve">
<value>Scanning for folders to remove in: {0}</value>
<comment>{0} is directory path.</comment>
</data>
<data name="CmdBlameCrashCollectAlwaysDescription" xml:space="preserve">
<value>Enables collecting crash dump on expected as well as unexpected testhost exit.</value>
</data>
Expand Down
1 change: 1 addition & 0 deletions src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public override RunApiOutput Execute()
{
CustomArtifactsPath = ArtifactsPath,
};
buildCommand.MarkArtifactsFolderUsed();

var runCommand = new RunCommand(
noBuild: false,
Expand Down
6 changes: 4 additions & 2 deletions src/Cli/dotnet/Commands/Run/RunCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ public int Execute()
if (EntryPointFileFullPath is not null)
{
Debug.Assert(!ReadCodeFromStdin);
projectFactory = CreateVirtualCommand().CreateProjectInstance;
var command = CreateVirtualCommand();
command.MarkArtifactsFolderUsed();
projectFactory = command.CreateProjectInstance;
}
}

Expand Down Expand Up @@ -589,7 +591,7 @@ public static RunCommand FromParseResult(ParseResult parseResult)
// If '-' is specified as the input file, read all text from stdin into a temporary file and use that as the entry point.
// We create a new directory for each file so other files are not included in the compilation.
// We fail if the file already exists to avoid reusing the same file for multiple stdin runs (in case the random name is duplicate).
string directory = VirtualProjectBuildingCommand.GetTempSubdirectory(Path.GetRandomFileName());
string directory = VirtualProjectBuildingCommand.GetTempSubpath(Path.GetRandomFileName());
VirtualProjectBuildingCommand.CreateTempSubdirectory(directory);
entryPointFilePath = Path.Join(directory, "app.cs");
using (var stdinStream = Console.OpenStandardInput())
Expand Down
Loading
Loading