Skip to content

Commit ea7c246

Browse files
jjonesczCopilot
andauthored
Automatically cleanup old artifacts of file-based apps (#49666)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent af1585c commit ea7c246

24 files changed

+917
-14
lines changed

documentation/general/dotnet-run-file.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,12 @@ The subdirectory is created by the SDK CLI with permissions restricting access t
184184
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.
185185
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.
186186

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

191194
## Directives for project metadata
192195

src/Cli/dotnet/Commands/Clean/CleanCommand.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public static CommandBase FromParseResult(ParseResult result, string? msbuildPat
3131
NoBuild = false,
3232
NoRestore = true,
3333
NoCache = true,
34-
NoBuildMarkers = true,
34+
NoWriteBuildMarkers = true,
3535
},
3636
static (msbuildArgs, msbuildPath) => new CleanCommand(msbuildArgs, msbuildPath),
3737
[ CommonOptions.PropertiesOption, CommonOptions.RestorePropertiesOption, CleanCommandParser.TargetOption, CleanCommandParser.VerbosityOption ],

src/Cli/dotnet/Commands/Clean/CleanCommandParser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.CommandLine;
5+
using Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
56
using Microsoft.DotNet.Cli.Extensions;
67

78
namespace Microsoft.DotNet.Cli.Commands.Clean;
@@ -59,6 +60,7 @@ private static Command ConstructCommand()
5960
command.Options.Add(NoLogoOption);
6061
command.Options.Add(CommonOptions.DisableBuildServersOption);
6162
command.Options.Add(TargetOption);
63+
command.Subcommands.Add(CleanFileBasedAppArtifactsCommandParser.Command);
6264

6365
command.SetAction(CleanCommand.Run);
6466

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
using System.Diagnostics;
6+
using System.Text.Json;
7+
using Microsoft.DotNet.Cli.Commands.Run;
8+
using Microsoft.DotNet.Cli.Utils;
9+
using Microsoft.DotNet.Cli.Utils.Extensions;
10+
11+
namespace Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
12+
13+
internal sealed class CleanFileBasedAppArtifactsCommand(ParseResult parseResult) : CommandBase(parseResult)
14+
{
15+
public override int Execute()
16+
{
17+
bool dryRun = _parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.DryRunOption);
18+
19+
using var metadataFileStream = OpenMetadataFile();
20+
21+
bool anyErrors = false;
22+
int count = 0;
23+
24+
foreach (var folder in GetFoldersToRemove())
25+
{
26+
if (dryRun)
27+
{
28+
Reporter.Verbose.WriteLine($"Would remove folder: {folder.FullName}");
29+
count++;
30+
}
31+
else
32+
{
33+
try
34+
{
35+
folder.Delete(recursive: true);
36+
Reporter.Verbose.WriteLine($"Removed folder: {folder.FullName}");
37+
count++;
38+
}
39+
catch (Exception ex)
40+
{
41+
Reporter.Error.WriteLine(string.Format(CliCommandStrings.CleanFileBasedAppArtifactsErrorRemovingFolder, folder, ex.Message).Red());
42+
anyErrors = true;
43+
}
44+
}
45+
}
46+
47+
Reporter.Output.WriteLine(
48+
dryRun
49+
? CliCommandStrings.CleanFileBasedAppArtifactsWouldRemoveFolders
50+
: CliCommandStrings.CleanFileBasedAppArtifactsTotalFoldersRemoved,
51+
count);
52+
53+
if (!dryRun)
54+
{
55+
UpdateMetadata(metadataFileStream);
56+
}
57+
58+
return anyErrors ? 1 : 0;
59+
}
60+
61+
private IEnumerable<DirectoryInfo> GetFoldersToRemove()
62+
{
63+
var directory = new DirectoryInfo(VirtualProjectBuildingCommand.GetTempSubdirectory());
64+
65+
if (!directory.Exists)
66+
{
67+
Reporter.Error.WriteLine(string.Format(CliCommandStrings.CleanFileBasedAppArtifactsDirectoryNotFound, directory.FullName).Yellow());
68+
yield break;
69+
}
70+
71+
Reporter.Output.WriteLine(CliCommandStrings.CleanFileBasedAppArtifactsScanning, directory.FullName);
72+
73+
var days = _parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.DaysOption);
74+
var cutoff = DateTime.UtcNow.AddDays(-days);
75+
76+
foreach (var subdir in directory.GetDirectories())
77+
{
78+
if (subdir.LastWriteTimeUtc < cutoff)
79+
{
80+
yield return subdir;
81+
}
82+
}
83+
}
84+
85+
private static FileInfo GetMetadataFile()
86+
{
87+
return new FileInfo(VirtualProjectBuildingCommand.GetTempSubpath(RunFileArtifactsMetadata.FilePath));
88+
}
89+
90+
private FileStream? OpenMetadataFile()
91+
{
92+
if (!_parseResult.GetValue(CleanFileBasedAppArtifactsCommandParser.AutomaticOption))
93+
{
94+
return null;
95+
}
96+
97+
// Open and lock the metadata file to ensure we are the only automatic cleanup process.
98+
return GetMetadataFile().Open(FileMode.Create, FileAccess.ReadWrite, FileShare.None);
99+
}
100+
101+
private static void UpdateMetadata(FileStream? metadataFileStream)
102+
{
103+
if (metadataFileStream is null)
104+
{
105+
return;
106+
}
107+
108+
var metadata = new RunFileArtifactsMetadata
109+
{
110+
LastAutomaticCleanupUtc = DateTime.UtcNow,
111+
};
112+
JsonSerializer.Serialize(metadataFileStream, metadata, RunFileJsonSerializerContext.Default.RunFileArtifactsMetadata);
113+
}
114+
115+
/// <summary>
116+
/// Starts a background process to clean up file-based app artifacts if needed.
117+
/// </summary>
118+
public static void StartAutomaticCleanupIfNeeded()
119+
{
120+
if (ShouldStartAutomaticCleanup())
121+
{
122+
Reporter.Verbose.WriteLine("Starting automatic cleanup of file-based app artifacts.");
123+
124+
var startInfo = new ProcessStartInfo
125+
{
126+
FileName = new Muxer().MuxerPath,
127+
Arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(
128+
[
129+
CleanCommandParser.GetCommand().Name,
130+
CleanFileBasedAppArtifactsCommandParser.Command.Name,
131+
CleanFileBasedAppArtifactsCommandParser.AutomaticOption.Name,
132+
]),
133+
UseShellExecute = false,
134+
RedirectStandardInput = true,
135+
RedirectStandardOutput = true,
136+
RedirectStandardError = true,
137+
CreateNoWindow = true,
138+
};
139+
140+
Process.Start(startInfo);
141+
}
142+
}
143+
144+
private static bool ShouldStartAutomaticCleanup()
145+
{
146+
if (Env.GetEnvironmentVariableAsBool("DOTNET_CLI_DISABLE_FILE_BASED_APP_ARTIFACTS_AUTOMATIC_CLEANUP", defaultValue: false))
147+
{
148+
return false;
149+
}
150+
151+
FileInfo? metadataFile = null;
152+
try
153+
{
154+
metadataFile = GetMetadataFile();
155+
156+
if (!metadataFile.Exists)
157+
{
158+
return true;
159+
}
160+
161+
using var stream = metadataFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
162+
var metadata = JsonSerializer.Deserialize(stream, RunFileJsonSerializerContext.Default.RunFileArtifactsMetadata);
163+
164+
if (metadata?.LastAutomaticCleanupUtc is not { } timestamp)
165+
{
166+
return true;
167+
}
168+
169+
// Start automatic cleanup every two days.
170+
return timestamp.AddDays(2) < DateTime.UtcNow;
171+
}
172+
catch (Exception ex)
173+
{
174+
Reporter.Verbose.WriteLine($"Cannot access artifacts metadata file '{metadataFile?.FullName}': {ex}");
175+
176+
// If the file cannot be accessed, automatic cleanup might already be running.
177+
return false;
178+
}
179+
}
180+
}
181+
182+
/// <summary>
183+
/// Metadata stored at the root level of the file-based app artifacts directory.
184+
/// </summary>
185+
internal sealed class RunFileArtifactsMetadata
186+
{
187+
public const string FilePath = "dotnet-run-file-artifacts-metadata.json";
188+
189+
public DateTime? LastAutomaticCleanupUtc { get; init; }
190+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.CommandLine;
5+
6+
namespace Microsoft.DotNet.Cli.Commands.Clean.FileBasedAppArtifacts;
7+
8+
internal sealed class CleanFileBasedAppArtifactsCommandParser
9+
{
10+
public static readonly Option<bool> DryRunOption = new("--dry-run")
11+
{
12+
Description = CliCommandStrings.CleanFileBasedAppArtifactsDryRun,
13+
Arity = ArgumentArity.Zero,
14+
};
15+
16+
public static readonly Option<int> DaysOption = new("--days")
17+
{
18+
Description = CliCommandStrings.CleanFileBasedAppArtifactsDays,
19+
DefaultValueFactory = _ => 30,
20+
};
21+
22+
/// <summary>
23+
/// Specified internally when the command is started automatically in background by <c>dotnet run</c>.
24+
/// Causes <see cref="RunFileArtifactsMetadata.LastAutomaticCleanupUtc"/> to be updated.
25+
/// </summary>
26+
public static readonly Option<bool> AutomaticOption = new("--automatic")
27+
{
28+
Hidden = true,
29+
};
30+
31+
public static Command Command => field ??= ConstructCommand();
32+
33+
private static Command ConstructCommand()
34+
{
35+
Command command = new("file-based-apps", CliCommandStrings.CleanFileBasedAppArtifactsCommandDescription)
36+
{
37+
Hidden = true,
38+
Options =
39+
{
40+
DryRunOption,
41+
DaysOption,
42+
AutomaticOption,
43+
},
44+
};
45+
46+
command.SetAction((parseResult) => new CleanFileBasedAppArtifactsCommand(parseResult).Execute());
47+
return command;
48+
}
49+
}

src/Cli/dotnet/Commands/CliCommandStrings.resx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,35 @@ Paths searched: '{1}', '{2}'.</value>
274274
<data name="CleanRuntimeOptionDescription" xml:space="preserve">
275275
<value>The target runtime to clean for.</value>
276276
</data>
277+
<data name="CleanFileBasedAppArtifactsCommandDescription" xml:space="preserve">
278+
<value>Removes artifacts created for file-based apps</value>
279+
</data>
280+
<data name="CleanFileBasedAppArtifactsDryRun" xml:space="preserve">
281+
<value>Determines changes without actually modifying the file system</value>
282+
</data>
283+
<data name="CleanFileBasedAppArtifactsDays" xml:space="preserve">
284+
<value>How many days an artifact folder needs to be unused in order to be removed</value>
285+
</data>
286+
<data name="CleanFileBasedAppArtifactsErrorRemovingFolder" xml:space="preserve">
287+
<value>Error removing folder '{0}': {1}</value>
288+
<comment>{0} is folder path. {1} is inner error message.</comment>
289+
</data>
290+
<data name="CleanFileBasedAppArtifactsWouldRemoveFolders" xml:space="preserve">
291+
<value>Would remove folders: {0}</value>
292+
<comment>{0} is count.</comment>
293+
</data>
294+
<data name="CleanFileBasedAppArtifactsTotalFoldersRemoved" xml:space="preserve">
295+
<value>Total folders removed: {0}</value>
296+
<comment>{0} is count.</comment>
297+
</data>
298+
<data name="CleanFileBasedAppArtifactsDirectoryNotFound" xml:space="preserve">
299+
<value>Warning: Artifacts directory does not exist: {0}</value>
300+
<comment>{0} is directory path.</comment>
301+
</data>
302+
<data name="CleanFileBasedAppArtifactsScanning" xml:space="preserve">
303+
<value>Scanning for folders to remove in: {0}</value>
304+
<comment>{0} is directory path.</comment>
305+
</data>
277306
<data name="CmdBlameCrashCollectAlwaysDescription" xml:space="preserve">
278307
<value>Enables collecting crash dump on expected as well as unexpected testhost exit.</value>
279308
</data>

src/Cli/dotnet/Commands/Run/Api/RunApiCommand.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ public override RunApiOutput Execute()
9393
{
9494
CustomArtifactsPath = ArtifactsPath,
9595
};
96+
buildCommand.MarkArtifactsFolderUsed();
9697

9798
var runCommand = new RunCommand(
9899
noBuild: false,

src/Cli/dotnet/Commands/Run/RunCommand.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,9 @@ public int Execute()
137137
if (EntryPointFileFullPath is not null)
138138
{
139139
Debug.Assert(!ReadCodeFromStdin);
140-
projectFactory = CreateVirtualCommand().CreateProjectInstance;
140+
var command = CreateVirtualCommand();
141+
command.MarkArtifactsFolderUsed();
142+
projectFactory = command.CreateProjectInstance;
141143
}
142144
}
143145

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

0 commit comments

Comments
 (0)