| File: ApplicationModel\AppHostLocalStateStore.cs | Web Access |
| Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.IO.Hashing; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Aspire.Shared; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; internal sealed class AppHostLocalStateStore { // State updates only hold the lock for a small local JSON read/write. Persistence is best-effort, // so use a short timeout rather than allowing the shared FileLock default to delay AppHost startup. private static readonly TimeSpan s_lockTimeout = TimeSpan.FromSeconds(5); private readonly ILogger<AppHostLocalStateStore> _logger; private readonly TimeSpan _lockTimeout; private readonly string? _stateDirectory; private readonly string? _statePath; private readonly string? _lockPath; private int _diskAccessDisabled; public AppHostLocalStateStore(IConfiguration configuration, ILogger<AppHostLocalStateStore> logger) : this(configuration, logger, s_lockTimeout) { } internal AppHostLocalStateStore( IConfiguration configuration, ILogger<AppHostLocalStateStore> logger, TimeSpan lockTimeout) { _logger = logger; _lockTimeout = lockTimeout; if (configuration["AppHost:Directory"] is not { Length: > 0 } appHostDirectory) { return; } // The deployment-state identity is derived from the AppHost path and is stable across // direct and CLI launches. DCP workload IDs are launcher-specific, so only use one as // a fallback when the AppHost path identities are unavailable. var identity = configuration["AppHost:DeploymentStatePathSha256"] ?? configuration["AppHost:FilePath"] ?? configuration["AppHost:Path"] ?? configuration[KnownConfigNames.DcpWorkloadId] ?? Path.GetFullPath(appHostDirectory); var identityHash = Convert.ToHexString(XxHash3.Hash(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant(); _stateDirectory = Path.Combine(appHostDirectory, ".aspire", "state"); _statePath = Path.Combine(_stateDirectory, $"{identityHash}.json"); _lockPath = _statePath + ".lock"; } // Keep reporting the store as available after a disk failure so callers continue to reserve // ports in memory even though persistence has been disabled for the rest of this process. public bool IsAvailable => _statePath is not null; public T Read<T>(string key, Func<JsonNode?, T> readValue, T defaultValue) { if (_statePath is null || _lockPath is null || IsDiskAccessDisabled) { return defaultValue; } try { using var fileLock = FileLock.Acquire(_lockPath, _lockTimeout); var state = LoadState(); return readValue(state[key]); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or TimeoutException) { if (DisableDiskAccess()) { _logger.LogWarning( exception, "Failed to read AppHost local state from {StatePath}. AppHost local state disk access is disabled for this process.", _statePath); } return defaultValue; } } public void Update(string key, Func<JsonNode?, JsonNode?> updateValue) { if (_stateDirectory is null || _statePath is null || _lockPath is null || IsDiskAccessDisabled) { return; } try { Directory.CreateDirectory(_stateDirectory); EnsureStateDirectoryIsIgnored(); if (IsDiskAccessDisabled) { return; } using var fileLock = FileLock.Acquire(_lockPath, _lockTimeout); var state = LoadState(); var currentValue = state[key]; var updatedValue = updateValue(currentValue?.DeepClone()); if (JsonNode.DeepEquals(currentValue, updatedValue)) { return; } if (updatedValue is null) { state.Remove(key); } else { state[key] = updatedValue; } WriteState(state); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or TimeoutException) { if (DisableDiskAccess()) { _logger.LogWarning( exception, "Failed to update AppHost local state in {StatePath}. AppHost local state disk access is disabled for this process.", _statePath); } } } private bool IsDiskAccessDisabled => Volatile.Read(ref _diskAccessDisabled) != 0; private bool DisableDiskAccess() { return Interlocked.Exchange(ref _diskAccessDisabled, 1) == 0; } private JsonObject LoadState() { if (!File.Exists(_statePath)) { return []; } try { if (JsonNode.Parse(File.ReadAllText(_statePath)) is not JsonObject root) { _logger.LogWarning("Ignoring invalid AppHost local state file {StatePath}.", _statePath); return []; } return root; } catch (JsonException exception) { _logger.LogWarning(exception, "Ignoring malformed AppHost local state file {StatePath}.", _statePath); return []; } catch (InvalidOperationException exception) { _logger.LogWarning(exception, "Ignoring invalid AppHost local state file {StatePath}.", _statePath); return []; } } private void WriteState(JsonObject state) { var statePath = _statePath ?? throw new InvalidOperationException("AppHost local state path was not initialized."); var temporaryPath = statePath + $".{Guid.NewGuid():N}.tmp"; try { File.WriteAllText(temporaryPath, state.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); File.Move(temporaryPath, statePath, overwrite: true); } finally { if (File.Exists(temporaryPath)) { File.Delete(temporaryPath); } } } private void EnsureStateDirectoryIsIgnored() { var ignorePath = Path.Combine(_stateDirectory!, ".gitignore"); if (File.Exists(ignorePath)) { return; } try { // State is derived from the AppHost model and must not require repository-specific ignore rules. using var stream = new FileStream(ignorePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); using var writer = new StreamWriter(stream); writer.WriteLine("*"); } catch (IOException) when (File.Exists(ignorePath)) { // Another AppHost process created the same product-owned ignore file. } } }