Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions src/Aspire.Hosting/Aspire.Hosting.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
<Compile Include="$(SharedDir)StringUtils.cs" Link="Utils\StringUtils.cs" />
<Compile Include="$(SharedDir)SchemaUtils.cs" Link="Utils\SchemaUtils.cs" />
<Compile Include="$(SharedDir)SecretsStore.cs" Link="Utils\SecretsStore.cs" />
<Compile Include="$(SharedDir)UserSecretsFileLock.cs" Link="Utils\UserSecretsFileLock.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogEntries.cs" Link="Utils\ConsoleLogs\LogEntries.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogEntry.cs" Link="Utils\ConsoleLogs\LogEntry.cs" />
<Compile Include="$(SharedDir)ConsoleLogs\LogPauseViewModel.cs" Link="Utils\ConsoleLogs\LogPauseViewModel.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ private sealed class SectionMetadata(long version)
private JsonObject? _state;
private bool _isStateLoaded;

/// <summary>
/// Gets the semaphore used for synchronizing state operations.
/// Can be overridden by derived classes to use a different synchronization mechanism.
/// </summary>
protected virtual SemaphoreSlim StateLock => _stateLock;

/// <inheritdoc/>
public abstract string? StateFilePath { get; }

Expand Down Expand Up @@ -149,7 +155,7 @@ private static void FlattenJsonObjectRecursive(JsonObject source, string prefix,
/// <returns>The loaded state as a JsonObject.</returns>
protected async Task<JsonObject> LoadStateAsync(CancellationToken cancellationToken = default)
{
await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
await StateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isStateLoaded && _state is not null)
Expand Down Expand Up @@ -181,21 +187,21 @@ protected async Task<JsonObject> LoadStateAsync(CancellationToken cancellationTo
}
finally
{
_stateLock.Release();
StateLock.Release();
}
}

/// <inheritdoc/>
public async Task SaveStateAsync(JsonObject state, CancellationToken cancellationToken = default)
{
await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
await StateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await SaveStateToStorageAsync(state, cancellationToken).ConfigureAwait(false);
}
finally
{
_stateLock.Release();
StateLock.Release();
}
}

Expand All @@ -219,8 +225,8 @@ public async Task<DeploymentStateSection> AcquireSectionAsync(string sectionName

var metadata = GetSectionMetadata(sectionName);

// Protect access to _state with _stateLock to prevent concurrent modification during enumeration
await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
// Protect access to _state with StateLock to prevent concurrent modification during enumeration
await StateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var sectionData = _state?.TryGetPropertyValue(sectionName, out var sectionNode) == true && sectionNode is JsonObject obj
Expand All @@ -231,7 +237,7 @@ public async Task<DeploymentStateSection> AcquireSectionAsync(string sectionName
}
finally
{
_stateLock.Release();
StateLock.Release();
}
}

Expand Down Expand Up @@ -268,7 +274,7 @@ public async Task SaveSectionAsync(DeploymentStateSection section, CancellationT
section.Version++;

// Serialize state modification and file write to prevent concurrent enumeration
await _stateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
await StateLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
// Store a deep clone to ensure immutability
Expand All @@ -277,7 +283,7 @@ public async Task SaveSectionAsync(DeploymentStateSection section, CancellationT
}
finally
{
_stateLock.Release();
StateLock.Release();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Configuration.UserSecrets;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.SecretManager.Tools.Internal;

namespace Aspire.Hosting.Publishing.Internal;

Expand All @@ -16,9 +17,27 @@ namespace Aspire.Hosting.Publishing.Internal;
/// </summary>
public sealed class UserSecretsDeploymentStateManager(ILogger<UserSecretsDeploymentStateManager> logger) : DeploymentStateManagerBase<UserSecretsDeploymentStateManager>(logger)
{
private SemaphoreSlim? _sharedSemaphore;

/// <inheritdoc/>
public override string? StateFilePath => GetStatePath();

/// <summary>
/// Gets the semaphore used for synchronizing state operations.
/// Uses the shared UserSecretsFileLock semaphore to coordinate with SecretsStore.
/// </summary>
protected override SemaphoreSlim StateLock
{
get
{
if (_sharedSemaphore == null && StateFilePath != null)
{
_sharedSemaphore = UserSecretsFileLock.GetSemaphore(StateFilePath);
}
return _sharedSemaphore ?? base.StateLock;
}
}

/// <inheritdoc/>
protected override string? GetStatePath()
{
Expand All @@ -37,7 +56,10 @@ protected override async Task SaveStateToStorageAsync(JsonObject state, Cancella
var userSecretsPath = GetStatePath() ?? throw new InvalidOperationException("User secrets path could not be determined.");
var flattenedUserSecrets = DeploymentStateManagerBase<UserSecretsDeploymentStateManager>.FlattenJsonObject(state);
Directory.CreateDirectory(Path.GetDirectoryName(userSecretsPath)!);
await File.WriteAllTextAsync(userSecretsPath, flattenedUserSecrets.ToJsonString(s_jsonSerializerOptions), cancellationToken).ConfigureAwait(false);

var json = flattenedUserSecrets.ToJsonString(s_jsonSerializerOptions);

await File.WriteAllTextAsync(userSecretsPath, json, System.Text.Encoding.UTF8, cancellationToken).ConfigureAwait(false);

logger.LogInformation("Azure resource connection strings saved to user secrets.");
}
Expand Down
104 changes: 78 additions & 26 deletions src/Shared/SecretsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,39 +49,50 @@ public SecretsStore(string userSecretsId)

public void Save()
{
EnsureUserSecretsDirectory();

var contents = new JsonObject();
if (_secrets is not null)
var semaphore = UserSecretsFileLock.GetSemaphore(_secretsFilePath);
semaphore.Wait();
try
{
foreach (var secret in _secrets.AsEnumerable())
// Reload from disk to merge with any concurrent changes
var currentSecrets = Load(_secretsFilePath);

// Merge our changes with what's on disk
foreach (var kvp in _secrets)
{
currentSecrets[kvp.Key] = kvp.Value;
Comment thread
davidfowl marked this conversation as resolved.
Outdated
}

Copilot AI Oct 31, 2025

Copy link

Choose a reason for hiding this comment

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

The merge logic doesn't handle deletions correctly. If _secrets has items removed via Remove(), those deletions won't be preserved when merging with currentSecrets loaded from disk. The removed keys will be restored from the file. Consider tracking deleted keys separately or using a different merge strategy that respects removals.

Copilot uses AI. Check for mistakes.

EnsureUserSecretsDirectory();

var contents = new JsonObject();
foreach (var secret in currentSecrets)
{
contents[secret.Key] = secret.Value;
}
}

// Create a temp file with the correct Unix file mode before moving it to the expected _filePath.
if (!OperatingSystem.IsWindows())
{
var tempFilename = Path.GetTempFileName();
File.Move(tempFilename, _secretsFilePath, overwrite: true);
}
// Create a temp file with the correct Unix file mode before moving it to the expected _filePath.
if (!OperatingSystem.IsWindows())
{
var tempFilename = Path.GetTempFileName();
File.Move(tempFilename, _secretsFilePath, overwrite: true);
}

var json = contents.ToJsonString(new()
{
WriteIndented = true
});
var json = contents.ToJsonString(new()
{
WriteIndented = true
});

File.WriteAllText(_secretsFilePath, json, Encoding.UTF8);
File.WriteAllText(_secretsFilePath, json, Encoding.UTF8);
}
finally
{
semaphore.Release();
}
}

private void EnsureUserSecretsDirectory()
{
var directoryName = Path.GetDirectoryName(_secretsFilePath);
if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
EnsureUserSecretsDirectory(_secretsFilePath);
}

private static Dictionary<string, string?> Load(string secretsFilePath)
Expand Down Expand Up @@ -130,14 +141,55 @@ public static bool TrySetUserSecret(Assembly? assembly, string name, string valu
// Save the value to the secret store
try
{
var secretsStore = new SecretsStore(userSecretsId);
secretsStore.Set(name, value);
secretsStore.Save();
return true;
var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId);
var semaphore = UserSecretsFileLock.GetSemaphore(secretsFilePath);
semaphore.Wait();
try
{
// Load, set, and save in one atomic operation to ensure thread safety
EnsureUserSecretsDirectory(secretsFilePath);

var secrets = Load(secretsFilePath);
secrets[name] = value;

Copilot AI Oct 31, 2025

Copy link

Choose a reason for hiding this comment

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

The code in TrySetUserSecret() (lines 155-173) duplicates the serialization logic from the Save() method (lines 67-85). Consider extracting this into a shared helper method to reduce duplication and ensure consistency between both code paths.

Copilot uses AI. Check for mistakes.

var contents = new JsonObject();
foreach (var secret in secrets)
{
contents[secret.Key] = secret.Value;
}

// Create a temp file with the correct Unix file mode before moving it to the expected _filePath.
if (!OperatingSystem.IsWindows())
{
var tempFilename = Path.GetTempFileName();
File.Move(tempFilename, secretsFilePath, overwrite: true);
}

var json = contents.ToJsonString(new()
{
WriteIndented = true
});

File.WriteAllText(secretsFilePath, json, Encoding.UTF8);
return true;
}
finally
{
semaphore.Release();
}
}
catch (Exception) { } // Ignore user secret store errors
}

return false;
}

private static void EnsureUserSecretsDirectory(string secretsFilePath)
{
var directoryName = Path.GetDirectoryName(secretsFilePath);
if (!string.IsNullOrEmpty(directoryName) && !Directory.Exists(directoryName))
{
Directory.CreateDirectory(directoryName);
}
}
}
38 changes: 38 additions & 0 deletions src/Shared/UserSecretsFileLock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Extensions.SecretManager.Tools.Internal;

/// <summary>
/// Provides synchronized access to user secrets files within the same process.
/// Both SecretsStore and UserSecretsDeploymentStateManager use this to ensure writes are serialized.
/// </summary>
internal static class UserSecretsFileLock

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is in a shared source file. I assume multiple projects will reference it. Each project's use of the shared file will have a separate static dictionary, and SemaphoreSlim instances won't actually be shared between projects.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yea in theory but its only shared by tests and the apphost at the moment.

{
// Static semaphore dictionary to synchronize access per file path
private static readonly Dictionary<string, SemaphoreSlim> s_semaphores = new();

Copilot AI Oct 31, 2025

Copy link

Choose a reason for hiding this comment

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

The static semaphore dictionary could lead to memory leaks in long-running processes, as semaphores are never removed once created. Consider implementing a cleanup mechanism or using a WeakReference-based approach if user secrets file paths can be dynamic and numerous over the application lifetime.

Copilot uses AI. Check for mistakes.
private static readonly object s_semaphoresLock = new();

/// <summary>
/// Gets a semaphore for the specified user secrets file path.
/// </summary>
/// <param name="filePath">The full path to the user secrets file.</param>
/// <returns>A SemaphoreSlim that can be used for synchronization.</returns>
public static SemaphoreSlim GetSemaphore(string filePath)
{
ArgumentNullException.ThrowIfNull(filePath);

// Normalize the path to ensure consistent locking across different path representations
var normalizedPath = Path.GetFullPath(filePath);

lock (s_semaphoresLock)
{
if (!s_semaphores.TryGetValue(normalizedPath, out var semaphore))
{
semaphore = new SemaphoreSlim(1, 1);
s_semaphores[normalizedPath] = semaphore;
}
return semaphore;
}
}
}
Loading
Loading