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
206 changes: 112 additions & 94 deletions docs/design/tools/illink/task-cache.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/tools/illink/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ View the official trimming docs at [learn.microsoft.com](https://learn.microsoft
## Design Docs
- [Reflection Handling](../../design/tools/illink/reflection-flow.md)
- [Task Cache](../../design/tools/illink/task-cache.md)
- [Shared Invocation Cache](../../../src/tasks/Common/InvocationCache/README.md)
17 changes: 11 additions & 6 deletions docs/tools/illink/illink-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ The cache helper resolves an unset or empty directory override to `$XDG_CACHE_HO
`$HOME/.cache/illink`) on Linux/other XDG Unix, `$HOME/Library/Caches/illink` on macOS,
and `LocalApplicationData/illink` on Windows. Relative `XDG_CACHE_HOME` values are ignored;
explicit relative cache directories are resolved against the working directory.
Keep the cache and output directories separate: neither may equal or contain the other.
For eligible cache-enabled invocations, overlap fails the task before output cleanup;
it does not fall back to uncached linking.

Eligible invocations use a key covering arguments, input-file and sidecar contents, the task
assembly, the selected dotnet host executable, and the entire ILLink assembly.
Expand Down Expand Up @@ -80,15 +83,17 @@ fall back to normal linking. See the

### Cache maintenance

The experimental `dotnet-illink-cache` .NET tool can purge entries that were not used recently:
The shared experimental `dotnet-build-cache` .NET tool can purge entries that were not used recently:

```sh
dotnet illink-cache purge --cache-directory /path/to/cache --before 2026-10-01T12:00:00Z
dotnet build-cache purge --cache illink --before 2026-10-01T12:00:00Z
```

The directory and UTC cutoff are required; maintenance does not use the task's opt-in or
directory environment variables. Use the tool built from the same revision as the task.
See the [tool README](../../../src/tools/illink/src/ILLink.CacheTool/README.md) for local
The UTC cutoff is required. `--cache illink` uses `ILLINK_EXPERIMENTAL_CACHE_PATH` or the
task's platform default. Alternatively, use `--cache-directory /path/to/cache` on its own
to select a directory directly. Maintenance ignores the task's boolean opt-in and
never creates missing directories. Use the tool built from the same revision as the task.
See the [tool README](../../../src/tools/BuildCache/README.md) for local
packaging and installation.

Each entry has a `last-used` timestamp, initialized during publication and refreshed after
Expand Down Expand Up @@ -118,7 +123,7 @@ or created by that build:
3. Run the build successfully and wait for all processes using the cache to finish.
4. Run the maintenance tool from the same checkout, with maintenance failures reported but nonfatal:
```sh
if ! dotnet illink-cache purge \
if ! dotnet build-cache purge \
--cache-directory "$ILLINK_EXPERIMENTAL_CACHE_PATH" \
--before "$ILLINK_CACHE_BUILD_START"; then
echo "Warning: ILLink cache purge failed; see the tool diagnostics above." >&2
Expand Down
17 changes: 12 additions & 5 deletions eng/Subsets.props
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
<!-- Respect the DotNetBuildTests product flag when building the product. -->
<DefaultLibrariesSubsets Condition="'$(DotNetBuildTests)' == 'true'">$(DefaultLibrariesSubsets)+libs.tests</DefaultLibrariesSubsets>

<DefaultToolsSubsets>tools.illink+tools.cdac</DefaultToolsSubsets>
<DefaultToolsSubsets>tools.buildcache+tools.illink+tools.cdac</DefaultToolsSubsets>

<DefaultHostSubsets>host.native+host.tools+host.pkg</DefaultHostSubsets>
<DefaultHostSubsets Condition="'$(DotNetBuildSourceOnly)' != 'true'">$(DefaultHostSubsets)+host.pretest+host.tests</DefaultHostSubsets>
Expand All @@ -173,7 +173,7 @@
<AllSubsetsExpansion>$(AllSubsetsExpansion)+clr.paltests+clr.paltestlist+clr.hosts+clr.jit+clr.alljits+clr.alljitscommunity+clr.spmi+clr.corelib+clr.nativecorelib+clr.tools+clr.toolstests+clr.r2rtests+clr.packages</AllSubsetsExpansion>
<AllSubsetsExpansion Condition="$([MSBuild]::IsOsPlatform(Windows))">$(AllSubsetsExpansion)+linuxdac+alpinedac</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+mono.runtime+provision.emsdk+mono.aotcross+mono.corelib+mono.manifests+mono.packages+mono.tools+mono.wasmruntime+mono.wasiruntime+mono.wasmworkload+mono.mscordbi</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+tools.illink+tools.cdac+tools.illinktests+tools.cdactests+tools.cdacdumptests</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+tools.buildcache+tools.illink+tools.cdac+tools.buildcachetests+tools.illinktests+tools.cdactests+tools.cdacdumptests</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+host.native+host.pkg+host.tools+host.pretest+host.tests</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+libs.native+libs.sfx+libs.oob+libs.pretest+libs.tests</AllSubsetsExpansion>
<AllSubsetsExpansion>$(AllSubsetsExpansion)+packs.product+packs.installers+packs.tests</AllSubsetsExpansion>
Expand Down Expand Up @@ -258,6 +258,8 @@

<!-- Tools -->
<SubsetName Include="Tools" Description="Additional runtime tools projects. Equivalent to: $(DefaultToolsSubsets)" />
<SubsetName Include="Tools.BuildCache" Description="Experimental build invocation cache maintenance tool." />
<SubsetName Include="Tools.BuildCacheTests" OnDemand="true" Description="Unit tests for the build invocation cache maintenance tool." />
<SubsetName Include="Tools.ILLink" Description="The projects that produce illink and analyzer tools for trimming." />
<SubsetName Include="Tools.Cdac" Description="Diagnostic data contract reader and related projects." />
<SubsetName Include="Tools.ILLinkTests" OnDemand="true" Description="Unit tests for the tools.illink subset." />
Expand Down Expand Up @@ -567,8 +569,15 @@
<ProjectToBuild Include="$(SharedNativeRoot)managed\cdac\tests\StressTests\Microsoft.Diagnostics.DataContractReader.StressTests.csproj" Test="true" Category="tools"/>
</ItemGroup>

<ItemGroup Condition="$(_subset.Contains('+tools.buildcache+')) or $(_subset.Contains('+tools.illink+'))">
<ProjectToBuild Include="$(ToolsProjectRoot)BuildCache\src\BuildCache.csproj" Category="tools" />
</ItemGroup>

<ItemGroup Condition="$(_subset.Contains('+tools.buildcachetests+')) or $(_subset.Contains('+tools.illinktests+'))">
<ProjectToBuild Include="$(ToolsProjectRoot)BuildCache\tests\BuildCache.Tests.csproj" Test="true" Category="tools" />
</ItemGroup>

<ItemGroup Condition="$(_subset.Contains('+tools.illink+'))">
<ProjectToBuild Include="$(ToolsProjectRoot)illink\src\ILLink.CacheTool\ILLink.CacheTool.csproj" Category="tools" />
<ProjectToBuild Include="$(ToolsProjectRoot)illink\src\linker\Mono.Linker.csproj" Category="tools" />
<ProjectToBuild Include="$(ToolsProjectRoot)illink\src\ILLink.Tasks\ILLink.Tasks.csproj" Category="tools" />
<ProjectToBuild Include="$(ToolsProjectRoot)illink\src\ILLink.RoslynAnalyzer\ILLink.RoslynAnalyzer.csproj" Category="tools" />
Expand All @@ -577,8 +586,6 @@
</ItemGroup>

<ItemGroup Condition="$(_subset.Contains('+tools.illinktests+'))">
<ProjectToBuild Include="$(ToolsProjectRoot)illink\test\ILLink.CacheTool.Tests\ILLink.CacheTool.Tests.csproj"
Test="true" Category="tools"/>
<ProjectToBuild Include="$(ToolsProjectRoot)illink\test\Mono.Linker.Tests\Mono.Linker.Tests.csproj"
Test="true" Category="tools"/>
<ProjectToBuild Include="$(ToolsProjectRoot)illink\test\Mono.Linker.Tests.Cases\Mono.Linker.Tests.Cases.csproj"
Expand Down
6 changes: 3 additions & 3 deletions eng/pipelines/coreclr/templates/purge-illink-cache.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ steps:
fi

tool=""
for candidate in "$(Build.SourcesDirectory)"/artifacts/bin/ILLink.CacheTool/*/net*/dotnet-illink-cache.dll; do
for candidate in "$(Build.SourcesDirectory)"/artifacts/bin/BuildCache/*/net*/dotnet-build-cache.dll; do
if [[ -f "$candidate" ]]; then
tool="$candidate"
break
Expand Down Expand Up @@ -64,13 +64,13 @@ steps:
exit 0
}

$toolRoot = Join-Path "$(Build.SourcesDirectory)" "artifacts/bin/ILLink.CacheTool"
$toolRoot = Join-Path "$(Build.SourcesDirectory)" "artifacts/bin/BuildCache"
if (-not (Test-Path -LiteralPath $toolRoot -PathType Container)) {
Write-Host "##vso[task.logissue type=warning]The ILLink cache maintenance tool was not built; skipping purge."
exit 1
}

$tool = Get-ChildItem -LiteralPath $toolRoot -Filter "dotnet-illink-cache.dll" -File -Recurse |
$tool = Get-ChildItem -LiteralPath $toolRoot -Filter "dotnet-build-cache.dll" -File -Recurse |
Sort-Object FullName | Select-Object -First 1
if ($null -eq $tool) {
Write-Host "##vso[task.logissue type=warning]The ILLink cache maintenance tool was not built; skipping purge."
Expand Down
165 changes: 165 additions & 0 deletions src/tasks/Common/InvocationCache.Tests/InvocationCachePurgeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable enable

using System;
using System.Globalization;
using System.IO;
using Xunit;

namespace Microsoft.NET.Build.Tasks.Tests;

public class InvocationCachePurgeTests
{
[Theory]
[InlineData(InvocationCacheEntry.VersionDirectory, -1, true)]
[InlineData(InvocationCacheEntry.VersionDirectory, 0, false)]
[InlineData(InvocationCacheEntry.VersionDirectory, 1, false)]
[InlineData("v2", -1, true)]
[InlineData("v2", 0, false)]
[InlineData("v2", 1, false)]
public void PurgeUsesLastUsedContentsAndExclusiveCutoff(string versionDirectory, int ticks, bool deleted)
{
using var cache = new TestCache(versionDirectory);
string entry = cache.AddEntry('a', TestCache.Cutoff.AddTicks(ticks));
File.SetLastWriteTimeUtc(Path.Combine(entry, InvocationCacheEntry.LastUsedFileName), DateTime.UtcNow);

Assert.Equal(0, cache.Run());

Assert.Equal(!deleted, Directory.Exists(entry));
Assert.Equal($"Test cache purge: Deleted: {(deleted ? 1 : 0)}, Kept: {(deleted ? 0 : 1)}, Errors: 0{Environment.NewLine}",
cache.Output.ToString());
Assert.Empty(cache.Error.ToString());
}

[Theory]
[InlineData(InvocationCacheEntry.VersionDirectory, "v2")]
[InlineData("v2", InvocationCacheEntry.VersionDirectory)]
[InlineData("other-layout", "v1")]
public void PurgeOnlyVisitsPublishedEntriesInSelectedLayout(string versionDirectory, string otherVersionDirectory)
{
using var cache = new TestCache(versionDirectory);
string old = cache.AddEntry('a', TestCache.Cutoff.AddDays(-1));
string recent = cache.AddEntry('b', TestCache.Cutoff.AddDays(1));
foreach (string name in new[] { new string('a', 64) + ".unique.tmp", "unrelated", new string('E', 64), new string('c', 63) })
{
Directory.CreateDirectory(Path.Combine(cache.Entries, name));
}
string otherVersion = Path.Combine(cache.Root, otherVersionDirectory, new string('d', 64));
Directory.CreateDirectory(otherVersion);
File.WriteAllText(Path.Combine(cache.Entries, "unrelated-file"), "keep");

Assert.Equal(0, cache.Run());

Assert.False(Directory.Exists(old));
Assert.True(Directory.Exists(recent));
Assert.Equal(5, Directory.GetDirectories(cache.Entries).Length);
Assert.True(Directory.Exists(otherVersion));
Assert.Equal("keep", File.ReadAllText(Path.Combine(cache.Entries, "unrelated-file")));
Assert.Contains("Deleted: 1, Kept: 1, Errors: 0", cache.Output.ToString());
}

[Theory]
[InlineData("missing", false)]
[InlineData("malformed", false)]
[InlineData("empty", false)]
[InlineData("directory", false)]
[InlineData("non-utc", false)]
[InlineData("missing", true)]
[InlineData("malformed", true)]
[InlineData("empty", true)]
[InlineData("directory", true)]
[InlineData("non-utc", true)]
public void InvalidMarkerUsesEntryCreationTime(string damage, bool delete)
{
using var cache = new TestCache();
string damagedEntry = cache.AddEntry('a', TestCache.Cutoff.AddDays(-1));
string validEntry = cache.AddEntry('b', TestCache.Cutoff.AddDays(-1));
string marker = Path.Combine(damagedEntry, InvocationCacheEntry.LastUsedFileName);
switch (damage)
{
case "missing":
File.Delete(marker);
break;
case "malformed":
File.WriteAllText(marker, "invalid timestamp");
break;
case "empty":
File.WriteAllText(marker, "");
break;
case "directory":
File.Delete(marker);
Directory.CreateDirectory(marker);
break;
case "non-utc":
File.WriteAllText(marker, TestCache.Cutoff.ToOffset(TimeSpan.FromHours(1)).ToString("O", CultureInfo.InvariantCulture));
break;
}

Assert.Equal(0, cache.Run(delete ? DateTimeOffset.UtcNow.AddMinutes(1) : TestCache.Cutoff));

Assert.Equal(!delete, Directory.Exists(damagedEntry));
Assert.False(Directory.Exists(validEntry));
Assert.Contains(damagedEntry, cache.Error.ToString());
Assert.Contains("using directory creation time", cache.Error.ToString());
Assert.Equal($"Test cache purge: Deleted: {(delete ? 2 : 1)}, Kept: {(delete ? 0 : 1)}, Errors: 0{Environment.NewLine}",
cache.Output.ToString());
}

[Fact]
public void MissingVersionDirectoryIsReported()
{
using var cache = new TestCache();
Directory.Delete(cache.Entries);

Assert.Equal(1, cache.Run());

Assert.StartsWith($"Could not enumerate Test cache '{cache.Entries}':", cache.Error.ToString());
Assert.Contains("Errors: 1", cache.Output.ToString());
Assert.False(Directory.Exists(cache.Entries));
}

[Fact]
public void EmptyCacheSucceeds()
{
using var cache = new TestCache();
Assert.Equal(0, cache.Run());
Assert.Contains("Deleted: 0, Kept: 0, Errors: 0", cache.Output.ToString());
}

internal sealed class TestCache : IDisposable
{
internal static readonly DateTimeOffset Cutoff = new(2025, 1, 2, 3, 4, 5, TimeSpan.Zero);
internal string Root { get; } = Path.Combine(Path.GetTempPath(), "invocation-cache-purge-tests-" + Guid.NewGuid().ToString("N"));
internal string VersionDirectory { get; }
internal string Entries => Path.Combine(Root, VersionDirectory);
internal StringWriter Output { get; } = new();
internal StringWriter Error { get; } = new();

internal TestCache(string versionDirectory = InvocationCacheEntry.VersionDirectory)
{
VersionDirectory = versionDirectory;
Directory.CreateDirectory(Entries);
}

internal string AddEntry(char key, DateTimeOffset lastUsed)
{
string entry = Path.Combine(Entries, new string(key, 64));
Directory.CreateDirectory(Path.Combine(entry, "outputs"));
File.WriteAllText(Path.Combine(entry, "outputs", "app.dll"), "cached output");
File.WriteAllText(Path.Combine(entry, InvocationCacheEntry.LastUsedFileName), lastUsed.ToString("O", CultureInfo.InvariantCulture));
return entry;
}

internal int Run(DateTimeOffset? cutoff = null) =>
InvocationCachePurge.Run(Root, VersionDirectory, "Test", cutoff ?? Cutoff, Output, Error);

public void Dispose()
{
Output.Dispose();
Error.Dispose();
Directory.Delete(Root, recursive: true);
}
}
}
Loading
Loading