diff --git a/AGENTS.md b/AGENTS.md index 050646c..5bcd3b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost - `WorkspaceStartupResolver` — discovers AL projects (mirrors `almcp`'s own `DiscoverProjectPaths`: downward scan for `app.json`, depth 4, standard exclusions) and composes the child `almcp`'s `--projects` / `--codeanalyzers` / `--rulesetpath` / `--packagecachepath` args. `almcp` in MCP mode never reads `.vscode/settings.json` and has no per-call analyzer, ruleset or package-cache parameter, so this bridge at launch is the only thing keeping `al_compile` and our fix tools in agreement (`ProjectLoader` reads the same `al.packageCachePath` for the in-process compilation). - `AlMcpProxy` — child process lifecycle plus generic tool forwarding over a single long-lived MCP client that reconnects on session expiry. `ForwardAsync` is a passthrough with **no per-tool argument rewriting**; configuration is conveyed at launch instead. - `ProjectAnalyzerResolver` — reads `al.codeAnalyzers` and the ruleset (`.vscode/settings.json`, `.AL-Go/settings.json`, convention-named files) and builds an `AnalyzerSet`. Nothing is built in. - - `AlcopsAnalyzerProvisioner` — downloads ALCops' own analyzers from NuGet, matched to the installed DevTools TFM, and caches them under `~/.alcops/analyzers/`. `Task Ready` completes with the provisioned folder or `null`. Configured via `--alcops-analyzers` / `ALCOPS_ANALYZERS` / `ALCOPS_ANALYZERS_CACHE`. + - `AlcopsAnalyzerProvisioner` — cache-first: when a valid cached version exists, `Task Ready` completes immediately with it and a background task checks NuGet for a newer version (for the next start). `internal Task BackgroundRefresh` is that background task, drained by `ProvisionAsync` and cancelled by `AlcopsAnalyzerProvisionerStartup.StopAsync`. On a cold cache or with a pinned version, the provisioner fetches from NuGet before completing. Internal HTTP timeouts surface as `TimeoutException` and fall back to cache; only the caller's cancellation propagates. Version ordering is SemVer 2 via `SemanticVersion`. The `.in-use` lock (`FileShare.None`) is a cross-process lock via `flock` on Unix, reliable on local file systems and advisory on network mounts such as NFS home directories. Configured via `--alcops-analyzers` / `ALCOPS_ANALYZERS` / `ALCOPS_ANALYZERS_CACHE`. - `ExternalAnalyzerLoader` — loads analyzer DLLs through `AnalyzerAssemblyLoadContext`, which resolves shared types by simple name from the default context. That type sharing is what makes `typeof(DiagnosticAnalyzer).IsAssignableFrom` work, and therefore what makes in-process code fixes possible at all. - `ProjectSessionManager` / `ProjectLoader` — caches AL project workspaces keyed by path; `GetOrLoadProjectAsync` is the entry point tools use. - **Models/** — record types for tool return values, serialized with `JsonDefaults.Options` (camelCase, not indented). @@ -57,7 +57,7 @@ It is deliberately **thin**: Microsoft's `almcp` already compiles, runs diagnost Shipping pinned cop DLLs beside whatever `Nav.CodeAnalysis` the user installed is what caused `AD0001` / `MissingMethodException` (issue #10). Microsoft cops and third-party analyzers come solely from the project's own config and the DevTools directory. `ALCops.Analyzers` is referenced by the **test project only**, so the fixtures have real cops with real code fixes to exercise; it must never move back to `src`. -ALCops' own analyzers are provisioned by `AlcopsAnalyzerProvisioner` at every startup: it detects the DevTools TFM, downloads the latest stable `ALCops.Analyzers` NuGet package (or uses a pinned/prerelease version per `--alcops-analyzers`), extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. `ExternalAnalyzerLoader.ResolveDllPath` probes the provisioned folder first for `${analyzerFolder}ALCops.*.dll` specs. The DevTools themselves are never downloaded at runtime. +ALCops' own analyzers are provisioned by `AlcopsAnalyzerProvisioner` with a cache-first strategy: it detects the DevTools TFM; when a valid cached version exists, `Ready` completes immediately with it and a background task checks NuGet for a newer version (cached for the next start). On a cold cache (first run), it downloads the latest stable `ALCops.Analyzers` NuGet package (or the pinned/prerelease version per `--alcops-analyzers`), extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. "Latest" means latest as of the previous run once a cache exists. `ExternalAnalyzerLoader.ResolveDllPath` probes the provisioned folder first for `${analyzerFolder}ALCops.*.dll` specs. The DevTools themselves are never downloaded at runtime. When passing analyzers to the child `almcp`, their sibling dependencies must travel with them (`ALCops.Common.dll`, `Microsoft.Dynamics.Nav.Analyzers.Common.dll`): `almcp` resolves analyzer dependencies only among the paths it was given and does not probe the analyzer's directory. A missing one turns every rule in that assembly into an `AD0001` instead of a diagnostic. diff --git a/README.md b/README.md index 5ff8660..0f105a4 100644 --- a/README.md +++ b/README.md @@ -78,20 +78,20 @@ This is deliberate: bundling pinned cop DLLs beside whatever `Nav.CodeAnalysis` ### ALCops analyzer provisioning -ALCops' own analyzers (`${analyzerFolder}ALCops.*.dll`) are provisioned automatically at every startup. The server detects the installed DevTools' target framework (e.g. `net10.0`), downloads the latest stable [ALCops.Analyzers](https://www.nuget.org/packages/ALCops.Analyzers) NuGet package, extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. On subsequent starts a newer stable version is picked up automatically; older cached versions are left in place. +ALCops' own analyzers (`${analyzerFolder}ALCops.*.dll`) are provisioned automatically at every startup. The server detects the installed DevTools' target framework (e.g. `net10.0`), and on the first start downloads the latest stable [ALCops.Analyzers](https://www.nuget.org/packages/ALCops.Analyzers) NuGet package, extracts the matching `lib//` folder, and caches the DLLs under `~/.alcops/analyzers///`. On later starts the newest cached version is used immediately so `almcp` launches without waiting on NuGet; a NuGet check and any download run in the background and a newer version is used on the **next** start. Older cached versions are left in place. Configure with `--alcops-analyzers` or the `ALCOPS_ANALYZERS` environment variable: | Value | Behaviour | |-------|-----------| -| `latest` (default) | Download the latest stable release. | -| `prerelease` | Download the highest version including prereleases. | +| `latest` (default) | Newest cached stable release; a newer one is fetched in the background for the next start (the first run downloads before starting). | +| `prerelease` | Highest version including prereleases from cache; a newer one is fetched in the background for the next start (the first run downloads before starting). | | `` (e.g. `1.2.0`) | Pin to a specific version (no index lookup). | | `off` | Disable provisioning entirely. | Set `ALCOPS_ANALYZERS_CACHE` to override the default cache directory (`~/.alcops/analyzers`). -When offline, the newest previously cached version for the target TFM is used with a warning. When no cache exists, the server starts without ALCops analyzers and logs a message with manual provisioning instructions. +When NuGet is unreachable or slow, the newest previously cached version for the target TFM is used with a warning. When no cache exists, the server starts without ALCops analyzers and logs a message with manual provisioning instructions. The recommended `al.codeAnalyzers` configuration: diff --git a/src/ALCops.Mcp/McpHost.cs b/src/ALCops.Mcp/McpHost.cs index 00ea079..67725ba 100644 --- a/src/ALCops.Mcp/McpHost.cs +++ b/src/ALCops.Mcp/McpHost.cs @@ -53,8 +53,9 @@ public static async Task RunAsync(string[] args, BcToolsLocator toolsLocator, Pr sp.GetRequiredService(), sp.GetRequiredService())); - // ALCops analyzer provisioner: downloads ALCops' own analyzers from NuGet, matched to the - // installed DevTools TFM. Runs under --no-proxy too — the native fix tools need them. + // ALCops analyzer provisioner: uses the newest cached ALCops analyzers immediately and + // refreshes from NuGet in the background, matched to the installed DevTools TFM. + // Runs under --no-proxy too — the native fix tools need them. var analyzersOption = AlcopsAnalyzersOption.Parse( proxyOptions.AlcopsAnalyzers ?? Environment.GetEnvironmentVariable("ALCOPS_ANALYZERS")); diff --git a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs index 10ea5ed..872cc2a 100644 --- a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs +++ b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisioner.cs @@ -38,10 +38,18 @@ internal sealed class AlcopsAnalyzerProvisioner : IDisposable private readonly ILogger _logger; private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private FileStream? _inUseLock; + private Task _backgroundRefresh = Task.CompletedTask; internal static readonly TimeSpan StaleTempDirectoryAge = TimeSpan.FromHours(1); public Task Ready => _ready.Task; + + /// + /// The NuGet check/download that runs after completed from cache. + /// Never faults; on cold-cache, pinned and off paths. + /// Awaited by so shutdown waits for it. + /// + internal Task BackgroundRefresh => _backgroundRefresh; internal TimeSpan ResolveTimeout { get; init; } = TimeSpan.FromSeconds(10); internal TimeSpan DownloadTimeout { get; init; } = TimeSpan.FromSeconds(60); @@ -68,6 +76,8 @@ public void Dispose() _httpClient.Dispose(); } + // FileShare.None is a cross-process lock via flock on Unix; reliable on local file systems, + // advisory on network mounts such as NFS home directories. private static FileStream AcquireInUseLock(string dir) => new(Path.Combine(dir, InUseLockFileName), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.None); @@ -86,6 +96,7 @@ public async Task ProvisionAsync(CancellationToken ct) } _ready.TrySetResult(result); + await _backgroundRefresh; } private async Task ProvisionCoreAsync(CancellationToken ct) @@ -110,23 +121,29 @@ public async Task ProvisionAsync(CancellationToken ct) _logger.LogInformation("DevTools target framework: {Tfm}", tfm); SweepStaleTempDirectories(); + ct.ThrowIfCancellationRequested(); - string? version; - try - { - version = await ResolveVersionAsync(ct); - } - catch (Exception ex) when (ex is not OperationCanceledException) + if (_option.Mode is AlcopsAnalyzersMode.Latest or AlcopsAnalyzersMode.Prerelease) { - _logger.LogWarning(ex, "Could not reach NuGet to resolve ALCops analyzer version"); - return FallbackToCacheOrWarn(tfm); + var cached = FindNewestCachedVersion(tfm, includePrerelease: _option.Mode == AlcopsAnalyzersMode.Prerelease); + if (cached is not null) + { + _logger.LogInformation( + "ALCops analyzers: v{Version} ({Tfm}) from cache {Dir}; checking NuGet in the background", + Path.GetFileName(cached), tfm, cached); + _backgroundRefresh = RefreshCacheAsync(tfm, cached, ct); + return cached; + } } + return await FetchOrFallbackAsync(tfm, ct); + } + + private async Task FetchAsync(string tfm, CancellationToken ct) + { + var version = await ResolveVersionAsync(ct); if (version is null) - { - _logger.LogWarning("No suitable ALCops analyzer version found on NuGet"); - return FallbackToCacheOrWarn(tfm); - } + return null; _logger.LogInformation("ALCops analyzers: resolved version {Version}", version); @@ -137,14 +154,43 @@ public async Task ProvisionAsync(CancellationToken ct) return cacheDir; } + return await DownloadAndExtractAsync(version, tfm, ct); + } + + private async Task FetchOrFallbackAsync(string tfm, CancellationToken ct) + { try { - return await DownloadAndExtractAsync(version, tfm, ct); + var result = await FetchAsync(tfm, ct); + if (result is not null) + return result; + + _logger.LogWarning("No suitable ALCops analyzer version found on NuGet"); } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning(ex, "Failed to download ALCops.Analyzers {Version}", version); - return FallbackToCacheOrWarn(tfm); + _logger.LogWarning(ex, "ALCops analyzers: NuGet provisioning failed"); + } + + return FallbackToCacheOrWarn(tfm); + } + + private async Task RefreshCacheAsync(string tfm, string current, CancellationToken ct) + { + try + { + var result = await FetchAsync(tfm, ct); + if (result is not null && !string.Equals(result, current, StringComparison.OrdinalIgnoreCase)) + _logger.LogInformation( + "ALCops analyzers: fetched v{Version} ({Tfm}); it will be used on next start", + Path.GetFileName(result), tfm); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + _logger.LogWarning(ex, + "ALCops analyzers: background refresh failed; keeping v{Version}", + Path.GetFileName(current)); } } @@ -316,12 +362,26 @@ internal string ExtractPackage(string nupkgPath, string version, string tfm, str } } + /// + /// Falls back to the newest cached version, including prereleases: loading a prerelease + /// with a warning beats running without ALCops rules when NuGet is unreachable. + /// private string? FallbackToCacheOrWarn(string tfm) { var cached = FindNewestCachedVersion(tfm); if (cached is not null) { - _logger.LogWarning("ALCops analyzers: using cached version from {Dir}", cached); + if (_option.Mode == AlcopsAnalyzersMode.Latest + && SemanticVersion.TryParse(Path.GetFileName(cached), out var v) && !v.IsStable) + { + _logger.LogWarning( + "ALCops analyzers: NuGet unreachable and no stable version cached; using prerelease v{Version} from {Dir} as a last resort", + Path.GetFileName(cached), cached); + } + else + { + _logger.LogWarning("ALCops analyzers: using cached version from {Dir}", cached); + } return cached; } @@ -333,7 +393,7 @@ internal string ExtractPackage(string nupkgPath, string version, string tfm, str return null; } - private string? FindNewestCachedVersion(string tfm) + private string? FindNewestCachedVersion(string tfm, bool includePrerelease = true) { var tfmDir = Path.Combine(_cacheRoot, tfm); if (!Directory.Exists(tfmDir)) @@ -353,6 +413,8 @@ internal string ExtractPackage(string nupkgPath, string version, string tfm, str continue; if (!SemanticVersion.TryParse(name, out var v)) continue; + if (!includePrerelease && !v.IsStable) + continue; if (bestVersion is null || v.CompareTo(bestVersion) > 0) { @@ -369,19 +431,24 @@ internal string ExtractPackage(string nupkgPath, string version, string tfm, str return best; } + // Three-level error isolation so a failure in one TFM folder or one temp + // directory never aborts the sweep for any other: + // 1. Outer (method-level): guards enumeration of the cache root itself. + // 2. Per-TFM-folder: guards enumeration of *.tmp-* within each TFM folder. + // 3. Per-directory: guards the probe/delete of each individual temp directory. private void SweepStaleTempDirectories() { - if (!Directory.Exists(_cacheRoot)) - return; - - var count = 0; try { - foreach (var tfmDir in Directory.EnumerateDirectories(_cacheRoot)) + if (!Directory.Exists(_cacheRoot)) + return; + + var count = 0; + foreach (var tfmDir in BcToolsLocator.SafeEnumerateDirectories(_cacheRoot)) { try { - foreach (var dir in Directory.EnumerateDirectories(tfmDir, "*.tmp-*")) + foreach (var dir in BcToolsLocator.SafeEnumerateDirectories(tfmDir, "*.tmp-*")) { try { @@ -389,7 +456,7 @@ private void SweepStaleTempDirectories() continue; try { using var probe = AcquireInUseLock(dir); } - catch (IOException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _logger.LogDebug("ALCops analyzers: skipping in-use extraction directory {Dir}", dir); continue; @@ -409,14 +476,14 @@ private void SweepStaleTempDirectories() _logger.LogDebug(ex, "ALCops analyzers: could not enumerate extraction directories in {Dir}", tfmDir); } } + + if (count > 0) + _logger.LogInformation("ALCops analyzers: removed {Count} stale extraction directories", count); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _logger.LogDebug(ex, "ALCops analyzers: could not enumerate TFM directories in {Dir}", _cacheRoot); + _logger.LogDebug(ex, "ALCops analyzers: sweep of stale extraction directories aborted"); } - - if (count > 0) - _logger.LogInformation("ALCops analyzers: removed {Count} stale extraction directories", count); } internal static bool IsCacheValid(string cacheDir) diff --git a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs index 6c0d6e5..2b4eed1 100644 --- a/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs +++ b/src/ALCops.Mcp/Services/AlcopsAnalyzerProvisionerStartup.cs @@ -3,6 +3,11 @@ namespace ALCops.Mcp.Services; +/// +/// Hosted service that runs on a background task. +/// cancels the token and awaits the startup task, which drains both +/// provisioning and the background NuGet refresh through . +/// internal sealed class AlcopsAnalyzerProvisionerStartup : IHostedService { private readonly AlcopsAnalyzerProvisioner _provisioner; diff --git a/src/ALCops.Mcp/Services/BcToolsLocator.cs b/src/ALCops.Mcp/Services/BcToolsLocator.cs index 19ae54e..28c4463 100644 --- a/src/ALCops.Mcp/Services/BcToolsLocator.cs +++ b/src/ALCops.Mcp/Services/BcToolsLocator.cs @@ -203,7 +203,7 @@ internal static IEnumerable OrderByDescendingVersion(IEnumerable .OrderByDescending(x => x.Version, SemanticVersion.Comparer) .Select(x => x.Path); - private static IEnumerable SafeEnumerateDirectories(string root, string pattern) + internal static IEnumerable SafeEnumerateDirectories(string root, string pattern = "*") { try { diff --git a/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs b/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs index 82381ac..f2b5cda 100644 --- a/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs +++ b/src/ALCops.Mcp/Services/WorkspaceStartupResolver.cs @@ -42,6 +42,7 @@ public sealed class WorkspaceStartupResolver private readonly ILogger _logger; private readonly string[]? _explicitProjects; private readonly Lazy _projectConfig; + private readonly Lazy> _fullConfig; public WorkspaceStartupResolver( ProjectAnalyzerResolver analyzerResolver, @@ -63,6 +64,7 @@ internal WorkspaceStartupResolver( _logger = logger; _explicitProjects = explicitProjects; _projectConfig = new Lazy(DiscoverProjectsOnly); + _fullConfig = new Lazy>(ResolveFullConfigAsync, LazyThreadSafetyMode.ExecutionAndPublication); } /// @@ -73,26 +75,22 @@ internal WorkspaceStartupResolver( /// /// Async: awaits the provisioner (if present) then resolves the full config including analyzers, - /// rulesets, and package cache paths. + /// rulesets, and package cache paths. The result is memoized. /// - public async Task GetConfigAsync() - { - if (_provisioner is not null) - { - var folder = await _provisioner.Ready; - if (folder is not null) - _logger.LogInformation("ALCops analyzers provisioned at {Folder}", folder); - } - - return ResolveFullConfig(); - } + public Task GetConfigAsync() => _fullConfig.Value; /// /// Composes the child almcp argument list, merged with any passthrough args the user gave /// us. User-supplied flags always win — we only fill in what they left unset. + /// Requires the full config to have resolved; throws if the provisioner is still running. /// - public string[] BuildAlMcpArgs(IReadOnlyList userArgs) => - ComposeAlMcpArgs(ResolveFullConfig(), userArgs); + public string[] BuildAlMcpArgs(IReadOnlyList userArgs) + { + var task = _fullConfig.Value; + if (!task.IsCompleted) + throw new InvalidOperationException("Analyzer provisioning is still running; use BuildAlMcpArgsAsync."); + return ComposeAlMcpArgs(task.GetAwaiter().GetResult(), userArgs); + } /// Async variant that awaits the provisioner before resolving the full config. public async Task BuildAlMcpArgsAsync(IReadOnlyList userArgs) => @@ -158,6 +156,18 @@ private WorkspaceStartupConfig DiscoverProjectsOnly() return new WorkspaceStartupConfig(projects, [], null); } + private async Task ResolveFullConfigAsync() + { + if (_provisioner is not null) + { + var folder = await _provisioner.Ready; + if (folder is not null) + _logger.LogInformation("ALCops analyzers provisioned at {Folder}", folder); + } + + return ResolveFullConfig(); + } + private WorkspaceStartupConfig ResolveFullConfig() { var projects = Config.ProjectDirectories; diff --git a/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs b/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs index 207f59e..45e58b1 100644 --- a/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs +++ b/tests/ALCops.Mcp.Tests/AlcopsAnalyzerProvisionerTests.cs @@ -118,6 +118,8 @@ public async Task Provision_ExtractsCorrectTfm_And_WritesManifest() File.ReadAllText(Path.Combine(result, ".alcops-manifest.json"))); Assert.Equal("1.2.0", manifest.RootElement.GetProperty("alcopsVersion").GetString()); Assert.Equal(Tfm, manifest.RootElement.GetProperty("requestedTfm").GetString()); + + Assert.True(provisioner.BackgroundRefresh.IsCompleted); } [Fact] @@ -483,6 +485,240 @@ public async Task Sweep_CoversEveryTfmFolder() Assert.False(Directory.Exists(otherTfmDir)); } + [Fact] + public async Task Sweep_EnumerationFailure_DoesNotFailProvisioning() + { + var cached = SeedCache("1.1.0"); + + // A stale *.tmp-* dir with a locked file: the sweep acquires the .in-use + // probe successfully but Directory.Delete throws IOException, exercising + // the per-directory catch on every platform. + var staleDir = Path.Combine(_cacheRoot, Tfm, "1.0.0.tmp-locked"); + Directory.CreateDirectory(staleDir); + Directory.SetLastWriteTimeUtc(staleDir, DateTime.UtcNow.AddHours(-2)); + var lockFile = Path.Combine(staleDir, "locked.bin"); + File.WriteAllBytes(lockFile, [0x00]); + + // A second TFM folder with a stale *.tmp-* dir that must be swept even + // when another TFM folder's enumeration fails or a directory in the first + // folder cannot be deleted. Named so neither alphabetical ordering is + // assumed — per-folder isolation makes both orders pass. + var sweepableStaleDir = Path.Combine(_cacheRoot, "other-tfm", "1.0.0.tmp-sweepable"); + Directory.CreateDirectory(sweepableStaleDir); + Directory.SetLastWriteTimeUtc(sweepableStaleDir, DateTime.UtcNow.AddHours(-2)); + + FileStream? holdLock = null; + string? unreadableDir = null; + try + { + holdLock = new FileStream(lockFile, FileMode.Open, FileAccess.Read, FileShare.None); + + // On Unix (non-root): make a TFM folder unreadable so the lazy enumerator + // inside SafeEnumerateDirectories throws on the first MoveNext, exercising + // the per-TFM-folder catch. On Windows, lazy-enumeration failures from + // concurrent deletions cannot be triggered deterministically; only the + // delete-failure and cross-folder-continuation cases are tested there. + if (!OperatingSystem.IsWindows() && !Environment.IsPrivilegedProcess) + { + unreadableDir = Path.Combine(_cacheRoot, "unreadable-tfm"); + Directory.CreateDirectory(unreadableDir); +#pragma warning disable CA1416 + File.SetUnixFileMode(unreadableDir, UnixFileMode.None); +#pragma warning restore CA1416 + } + + var handler = new FakeHandler { ThrowOnRequest = true }; + using var provisioner = Create(AlcopsAnalyzersOption.Latest, handler); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.Equal(cached, result); + Assert.True(Directory.Exists(staleDir), "Locked directory should survive the sweep"); + Assert.False(Directory.Exists(sweepableStaleDir), + "Stale dir in a separate TFM folder must still be swept"); + } + finally + { + holdLock?.Dispose(); + if (unreadableDir is not null) + { +#pragma warning disable CA1416 + File.SetUnixFileMode(unreadableDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); +#pragma warning restore CA1416 + } + } + } + + [Fact] + public async Task Provision_WarmCache_ReadyFromCache_RefreshDownloadsNewerForNextStart() + { + var dir110 = SeedCache("1.1.0"); + + var handler = new FakeHandler { Gate = new TaskCompletionSource() }; + handler.Respond(IndexUrl, IndexJson); + handler.Respond(NupkgUrl("1.2.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"))); + + using var p = Create(AlcopsAnalyzersOption.Latest, handler); + var run = p.ProvisionAsync(CancellationToken.None); + + Assert.Equal(dir110, await p.Ready.WaitAsync(TimeSpan.FromSeconds(5))); + Assert.False(p.BackgroundRefresh.IsCompleted); + + handler.Gate.SetResult(); + await p.BackgroundRefresh; + + Assert.True(AlcopsAnalyzerProvisioner.IsCacheValid(Path.Combine(_cacheRoot, Tfm, "1.2.0"))); + Assert.Equal(dir110, await p.Ready); + await run; + } + + [Fact] + public async Task Provision_WarmCache_Latest_UsesNewestCachedStable_NotPrerelease() + { + SeedCache("1.2.0"); + SeedCache("1.3.0-preview.1"); + + var handler = new FakeHandler { ThrowOnRequest = true }; + using var p = Create(AlcopsAnalyzersOption.Latest, handler); + await p.ProvisionAsync(CancellationToken.None); + var result = await p.Ready; + + Assert.NotNull(result); + Assert.EndsWith("1.2.0", Path.GetFileName(result)); + } + + [Fact] + public async Task Provision_WarmCache_Prerelease_UsesNewestCachedAny() + { + SeedCache("1.2.0"); + SeedCache("1.3.0-preview.1"); + + var handler = new FakeHandler { ThrowOnRequest = true }; + using var p = Create(AlcopsAnalyzersOption.Prerelease, handler); + await p.ProvisionAsync(CancellationToken.None); + var result = await p.Ready; + + Assert.NotNull(result); + Assert.EndsWith("1.3.0-preview.1", Path.GetFileName(result)); + } + + [Fact] + public async Task Provision_WarmCache_RefreshFailure_NeverThrows() + { + var dir110 = SeedCache("1.1.0"); + + var handler = new FakeHandler { ThrowOnRequest = true }; + using var p = Create(AlcopsAnalyzersOption.Latest, handler); + await p.ProvisionAsync(CancellationToken.None); + + Assert.Equal(dir110, await p.Ready); + Assert.Contains(IndexUrl, handler.RequestUrls); + Assert.True(p.BackgroundRefresh.IsCompletedSuccessfully); + } + + [Fact] + public async Task Provision_WarmCache_ShutdownCancelsRefresh() + { + var dir110 = SeedCache("1.1.0"); + + var handler = new FakeHandler { Gate = new TaskCompletionSource() }; + handler.Respond(IndexUrl, IndexJson); + handler.Respond(NupkgUrl("1.2.0"), + BuildFakeNupkg(($"{Tfm}", "ALCops.Fake.dll"))); + + using var cts = new CancellationTokenSource(); + using var p = Create(AlcopsAnalyzersOption.Latest, handler); + var run = p.ProvisionAsync(cts.Token); + + await p.Ready; + cts.Cancel(); + await run.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.False(Directory.Exists(Path.Combine(_cacheRoot, Tfm, "1.2.0"))); + } + + [Fact] + public async Task Provision_Pinned_Cached_ZeroRequests() + { + SeedCache("1.1.0"); + + var handler = new FakeHandler(); + using var p = Create(AlcopsAnalyzersOption.Parse("1.1.0"), handler); + await p.ProvisionAsync(CancellationToken.None); + var result = await p.Ready; + + Assert.NotNull(result); + Assert.Empty(handler.RequestUrls); + } + + [Fact] + public async Task Fallback_Latest_OnlyPrereleaseCached_UsesItWithExplicitWarning() + { + var dir = SeedCache("1.3.0-preview.1"); + + var handler = new FakeHandler { ThrowOnRequest = true }; + var logger = new CapturingLogger(); + using var provisioner = Create(AlcopsAnalyzersOption.Latest, handler, logger); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.Equal(dir, result); + Assert.Contains(logger.Entries, e => + e.Level == LogLevel.Warning && e.Message.Contains("no stable version cached")); + } + + // The Latest-mode fast path finds the newest stable version in cache and returns + // it immediately — the prerelease is excluded by includePrerelease: false. No NuGet + // request is needed for the fast path; no "last resort" warning is logged. + [Fact] + public async Task Provision_Latest_StableAndPrereleaseCached_FastPathUsesStable_NoLastResortWarning() + { + var stableDir = SeedCache("1.2.0"); + SeedCache("1.3.0-preview.1"); + + var handler = new FakeHandler { Gate = new TaskCompletionSource() }; + handler.Respond(IndexUrl, IndexJson); + var logger = new CapturingLogger(); + using var provisioner = Create(AlcopsAnalyzersOption.Latest, handler, logger); + var run = provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.Equal(stableDir, result); + Assert.Empty(handler.RequestUrls); + Assert.DoesNotContain(logger.Entries, e => + e.Level == LogLevel.Warning && e.Message.Contains("no stable version cached")); + + handler.Gate.SetResult(); + await run; + } + + // Documented last-resort behaviour: when the requested pinned version is unavailable + // and the nupkg download fails, FallbackToCacheOrWarn picks the highest cached + // version with includePrerelease: true. SemVer 2 orders 1.3.0-preview.1 > 1.2.0 + // (higher base version wins; stability only breaks ties at equal base). This is not + // a preference for prereleases — it is the most capable version available offline. + [Fact] + public async Task Fallback_Prerelease_PicksHighestCachedAcrossStableAndPrerelease() + { + SeedCache("1.2.0"); + SeedCache("1.3.0-preview.1"); + + var handler = new FakeHandler { ThrowOnRequest = true }; + var logger = new CapturingLogger(); + using var provisioner = Create(AlcopsAnalyzersOption.Parse("9.9.9"), handler, logger); + await provisioner.ProvisionAsync(CancellationToken.None); + var result = await provisioner.Ready; + + Assert.NotNull(result); + Assert.EndsWith("1.3.0-preview.1", Path.GetFileName(result)); + Assert.Contains(logger.Entries, e => + e.Level == LogLevel.Warning && e.Message.Contains("using cached version")); + } + private sealed class CapturingLogger : ILogger { public List<(LogLevel Level, string Message)> Entries { get; } = []; @@ -556,16 +792,20 @@ protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var url = request.RequestUri!.ToString(); - RequestUrls.Add(url); cancellationToken.ThrowIfCancellationRequested(); if (ThrowOnRequest) + { + RequestUrls.Add(url); throw new HttpRequestException("Network unavailable (test)"); + } if (Gate is not null) await Gate.Task.WaitAsync(cancellationToken); + RequestUrls.Add(url); + if (Delays.TryGetValue(url, out var delay)) await Task.Delay(delay, cancellationToken); diff --git a/tests/ALCops.Mcp.Tests/WorkspaceStartupResolverTests.cs b/tests/ALCops.Mcp.Tests/WorkspaceStartupResolverTests.cs index 61fde7c..6b6ad04 100644 --- a/tests/ALCops.Mcp.Tests/WorkspaceStartupResolverTests.cs +++ b/tests/ALCops.Mcp.Tests/WorkspaceStartupResolverTests.cs @@ -221,6 +221,29 @@ public void BuildAlMcpArgs_ProjectWithoutAnalyzerConfig_OmitsCodeAnalysisFlags() Assert.DoesNotContain("--packagecachepath", args); } + [Fact] + public async Task GetConfigAsync_IsMemoized() + { + var project = CreateProject("App"); + Directory.CreateDirectory(Path.Combine(project, ".vscode")); + File.WriteAllText( + Path.Combine(project, ".vscode", "settings.json"), + """{ "al.packageCachePath": "../a" }"""); + + var resolver = CreateResolver(project); + var args1 = resolver.BuildAlMcpArgs([]); + + File.WriteAllText( + Path.Combine(project, ".vscode", "settings.json"), + """{ "al.packageCachePath": "../b" }"""); + + var args2 = resolver.BuildAlMcpArgs([]); + Assert.Equal("../a", ValueOf(args1, "--packagecachepath")); + Assert.Equal("../a", ValueOf(args2, "--packagecachepath")); + + Assert.Same(await resolver.GetConfigAsync(), await resolver.GetConfigAsync()); + } + private static string? ValueOf(string[] args, string flag) { var index = Array.IndexOf(args, flag);