File: Devcontainers\DevcontainerSettingsWriter.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.Globalization;
using System.Text.Json.Nodes;
using System.Threading.Channels;
using Aspire.Hosting.Devcontainers.Codespaces;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Devcontainers;
 
internal class DevcontainerSettingsWriter(ILogger<DevcontainerSettingsWriter> logger, IOptions<CodespacesOptions> codespaceOptions, IOptions<DevcontainersOptions> devcontainerOptions, IOptions<SshRemoteOptions> sshRemoteOptions) : IDisposable
{
    // Define path segments that will be combined with the user's home directory
    // These path segments are relative to the user's home directory
    // instead of hardcoding to a specific user (e.g., "vscode")
    private const string VscodeRemotePathSegment = ".vscode-remote/data/Machine/settings.json";
    private const string VscodeServerPathSegment = ".vscode-server";
    private const string VscodeInsidersServerPathSegment = ".vscode-server-insiders";
    private const string LocalDevcontainerSettingsPath = "data/Machine/settings.json";
    private const string PortAttributesFieldName = "remote.portsAttributes";
    private const int WriteLockTimeoutMs = 2000;
    private static readonly TimeSpan s_portForwardLogDelay = TimeSpan.FromSeconds(5);
 
    private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1);
    private readonly Channel<PortForwardEntry> _portUpdates = Channel.CreateUnbounded<PortForwardEntry>(new UnboundedChannelOptions
    {
        SingleReader = true,
        SingleWriter = false
    });
    private readonly object _processingLock = new();
    private Task? _processingTask;
    private readonly CancellationTokenSource _processingCancellation = new();
 
    // Get the user's home directory using the Environment API
    // This ensures we work with any username in devcontainers/codespaces
    private static string GetUserHomeDirectory() =>
        Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 
    public void AddPortForward(string url, int port, string protocol, string label, bool openBrowser = false)
    {
        ArgumentException.ThrowIfNullOrEmpty(url);
        ArgumentException.ThrowIfNullOrEmpty(protocol);
        ArgumentException.ThrowIfNullOrEmpty(label);
 
        // Ensure the background processor is running.
        StartProcessingLoop();
 
        // Enqueue the new port forward entry.
        _portUpdates.Writer.TryWrite(new PortForwardEntry(url, port, protocol, label, openBrowser));
    }
 
    private void StartProcessingLoop()
    {
        lock (_processingLock)
        {
            if (_processingTask is not null)
            {
                return;
            }
 
            _processingTask = Task.Run(ProcessPortUpdatesAsync, _processingCancellation.Token);
        }
    }
 
    private async Task ProcessPortUpdatesAsync()
    {
        var reader = _portUpdates.Reader;
        try
        {
            while (await reader.WaitToReadAsync(_processingCancellation.Token).ConfigureAwait(false))
            {
                // Drain all currently available updates to batch writes and avoid excessive file I/O.
                List<PortForwardEntry> batch = [];
                while (reader.TryRead(out var entry))
                {
                    batch.Add(entry);
                }
 
                try
                {
                    await WriteSettingsAsync(batch, _processingCancellation.Token).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (_processingCancellation.IsCancellationRequested)
                {
                    // Shutting down.
                    break;
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Error writing Devcontainer port forwarding settings batch");
                }
            }
        }
        catch (OperationCanceledException) when (_processingCancellation.IsCancellationRequested)
        {
            // Normal shutdown.
        }
    }
 
    private async Task WriteSettingsAsync(IReadOnlyList<PortForwardEntry> newPorts, CancellationToken cancellationToken)
    {
        var settingsPaths = GetSettingsPaths();
        // Collect ports we actually wrote so we can log them AFTER the file save completes.
        List<(string Label, string Url)> portsToLog = [];
 
        foreach (var settingsPath in settingsPaths)
        {
            var acquired = await _writeLock.WaitAsync(WriteLockTimeoutMs, cancellationToken).ConfigureAwait(false);
 
            if (!acquired)
            {
                throw new DistributedApplicationException($"Failed to acquire semaphore for settings file: {settingsPath}");
            }
 
            await EnsureSettingsFileExists(settingsPath, cancellationToken).ConfigureAwait(false);
 
            var settingsContent = await File.ReadAllTextAsync(settingsPath, cancellationToken).ConfigureAwait(false);
            var settings = (JsonObject)JsonObject.Parse(settingsContent)!;
 
            JsonObject? portsAttributes;
            if (!settings.TryGetPropertyValue(PortAttributesFieldName, out var portsAttributesNode))
            {
                portsAttributes = [];
                settings.Add(PortAttributesFieldName, portsAttributes);
            }
            else
            {
                portsAttributes = (JsonObject)portsAttributesNode!;
            }
 
            // Data is keyed by port number, but we want to key it by label
            // e.g
            // {
            //     "remote.portsAttributes": {
            //         "8080": {
            //             "label": "MyApp",
            //             "protocol": "http",
            //             "onAutoForward": "openBrowser"
            //         }
            //     }
            // }
 
            var portsByLabel = (from props in portsAttributes
                                let attrs = props.Value as JsonObject
                                let forwardedPort = props.Key
                                let l = attrs["label"]?.ToString()
                                where l != null
                                select new { Label = l, Port = forwardedPort })
                                .ToLookup(p => p.Label, p => p.Port);
 
            foreach (var portEntry in newPorts)
            {
                var port = portEntry.Port.ToString(CultureInfo.InvariantCulture);
                var label = portEntry.Label;
                var protocol = portEntry.Protocol;
                var openBrowser = portEntry.OpenBrowser;
                var url = portEntry.Url;
                // Remove any existing ports with the same label
                foreach (var oldPort in portsByLabel[label])
                {
                    portsAttributes.Remove(oldPort);
                }
 
                JsonObject? portAttributes;
                if (!portsAttributes.TryGetPropertyValue(port, out var portAttributeNode))
                {
                    portAttributes = [];
                    portsAttributes.Add(port, portAttributes);
                }
                else
                {
                    portAttributes = (JsonObject)portAttributeNode!;
                }
 
                portAttributes["label"] = label;
                portAttributes["protocol"] = protocol;
                portAttributes["onAutoForward"] = openBrowser ? "openBrowser" : "silent";
 
                portsToLog.Add((label, url));
            }
 
            settingsContent = settings.ToString();
            await File.WriteAllTextAsync(settingsPath, settingsContent, cancellationToken).ConfigureAwait(false);
 
            _writeLock.Release();
        }
 
        if (portsToLog.Count > 0)
        {
            // Delay logging until after the settings file(s) have been updated for at least s_portForwardLogDelay.
            _ = Task.Run(async () =>
            {
                try
                {
                    await Task.Delay(s_portForwardLogDelay, cancellationToken).ConfigureAwait(false);
                    foreach (var (label, url) in portsToLog)
                    {
                        logger.LogInformation("Port forwarding ({label}): {Url}", label, url);
                    }
                }
                catch (OperationCanceledException)
                {
                    // Ignore cancellation
                }
            }, cancellationToken);
        }
 
        IEnumerable<string> GetSettingsPaths()
        {
            // Get the current user's home directory
            // This ensures we work with any username in the container, not just "vscode"
            var userHomeDir = GetUserHomeDirectory();
 
            // For some reason the machine settings path is different between Codespaces and local Devcontainers
            // so we decide which one to use here based on the options.
            if (codespaceOptions.Value.IsCodespace)
            {
                yield return Path.Combine(userHomeDir, VscodeRemotePathSegment);
            }
            else if (devcontainerOptions.Value.IsDevcontainer || sshRemoteOptions.Value.IsSshRemote)
            {
                var vscodeServerPath = Path.Combine(userHomeDir, VscodeServerPathSegment);
                var vscodeInsidersServerPath = Path.Combine(userHomeDir, VscodeInsidersServerPathSegment);
 
                if (Directory.Exists(vscodeServerPath))
                {
                    yield return Path.Combine(vscodeServerPath, LocalDevcontainerSettingsPath);
                }
 
                if (Directory.Exists(vscodeInsidersServerPath))
                {
                    yield return Path.Combine(vscodeInsidersServerPath, LocalDevcontainerSettingsPath);
                }
            }
            else
            {
                throw new DistributedApplicationException("Codespaces, Devcontainer, or SSH Remote not detected.");
            }
        }
 
        async Task EnsureSettingsFileExists(string path, CancellationToken cancellationToken)
        {
            try
            {
                if (!File.Exists(path))
                {
                    // Ensure the parent directory exists before attempting to create the file
                    Directory.CreateDirectory(Path.GetDirectoryName(path)!);
 
                    // The extra ceremony here is to avoid accidentally overwriting the file if it was
                    // created after we checked for its existence. If the file exists when we go to write
                    // it then we will throw and log a warning, but otherwise continue executing.
                    using var stream = File.Open(path, FileMode.CreateNew);
                    using var writer = new StreamWriter(stream);
                    await writer.WriteAsync("{}".AsMemory(), cancellationToken).ConfigureAwait(false);
                }
            }
            catch (IOException ex) when (ex.Message == $"The file '{path}' already exists.")
            {
                // This is OK, but it should be rare enough that if it starts happening we probably
                // want to know about it in logs that end users submit so we know to take a closer
                // look at what is going on.
                logger.LogWarning("Race condition detected when creating Devcontainer settings file: {Path}", path);
            }
        }
    }
 
    public void Dispose()
    {
        _processingCancellation.Cancel();
        _portUpdates.Writer.TryComplete();
    }
 
    private sealed record PortForwardEntry(string Url, int Port, string Protocol, string Label, bool OpenBrowser);
}