// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Aspire.Shared;
using Microsoft.Win32.SafeHandles;
namespace Aspire.Tray;
/// <summary>
/// Owns the per-user Linux singleton and the detached, acknowledged bundle handoff.
/// </summary>
[SupportedOSPlatform("linux")]
internal static partial class LinuxTrayRuntime
{
internal static string DirectoryPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspire", "tray", "runtime");
internal static string PipeName => Path.Combine(DirectoryPath, "control-v1.sock");
internal static string LogPath => Path.Combine(DirectoryPath, "aspire-tray.log");
internal static FileStream? TryAcquire(string directory)
{
PrepareDirectory(directory);
var handle = OpenPrivateFile(Path.Combine(directory, "instance.lock"), append: false);
if (flock(handle, 2 | 4) == 0)
{
// Never unlink the lock: contenders must continue to lock the same inode.
return new FileStream(handle, FileAccess.ReadWrite);
}
var error = Marshal.GetLastPInvokeError();
handle.Dispose();
if (error == 11) // Linux EWOULDBLOCK.
{
return null;
}
throw new Win32Exception(error, "Could not acquire the tray instance lock.");
}
private static void PrepareDirectory(string directory)
{
// The user profile and .aspire can be intentional aliases (for example
// dotfile-managed homes). Only our dedicated runtime directory must not
// redirect permission changes; runtime files are opened with O_NOFOLLOW.
if (new DirectoryInfo(directory).LinkTarget is not null)
{
throw new IOException("The tray runtime directory must not be a symbolic link.");
}
DirectoryHelper.CreateWithOwnerOnlyPermissions(directory);
}
internal static int GetPrivateFileFlags(Architecture architecture, bool append)
{
// Linux's ARM64 UAPI overrides the asm-generic O_NOFOLLOW value.
// https://github.com/torvalds/linux/blob/master/arch/arm64/include/uapi/asm/fcntl.h
var noFollow = architecture switch
{
Architecture.X64 => 0x20000,
Architecture.Arm64 => 0x8000,
_ => throw new PlatformNotSupportedException("Aspire Tray requires Linux x64 or ARM64.")
};
return 2 | 0x40 | noFollow | 0x80000 | (append ? 0x400 : 0);
}
private static SafeFileHandle OpenPrivateFile(string path, bool append)
{
// Linux O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC; reject substituted links.
var descriptor = open(path, GetPrivateFileFlags(RuntimeInformation.ProcessArchitecture, append), 0x180);
if (descriptor < 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not open the tray runtime file.");
}
var handle = new SafeFileHandle(descriptor, ownsHandle: true);
if (fchmod(handle, 0x180) != 0)
{
var error = Marshal.GetLastPInvokeError();
handle.Dispose();
throw new Win32Exception(error, "Could not restrict tray runtime file permissions.");
}
return handle;
}
internal static void Detach()
{
// This runs in the newly launched executable, not by forking a managed runtime.
// The child leaves the terminal session before initializing GTK or discovery.
if (setsid() < 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not detach the tray from the terminal.");
}
PrepareDirectory(DirectoryPath);
using var originalLog = OpenPrivateFile(LogPath, append: true);
using var originalInput = File.OpenHandle("/dev/null", FileMode.Open, FileAccess.Read);
using var log = DuplicateAboveStdio(originalLog);
using var input = DuplicateAboveStdio(originalInput);
// Close possible descriptors 0/1/2 before replacing them. Keeping independent
// descriptors above stdio also prevents one dup2 from clobbering another source.
originalLog.Dispose();
originalInput.Dispose();
if (dup2(log, 1) < 0 || dup2(log, 2) < 0 || dup2(input, 0) < 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not redirect detached tray streams.");
}
// setsid() leaves the terminal session but not the launcher's cgroup.
LinuxSessionBus.MoveOutOfServiceUnit();
}
private static SafeFileHandle DuplicateAboveStdio(SafeFileHandle handle)
{
var descriptor = fcntl(handle, 1030, 3); // Linux F_DUPFD_CLOEXEC.
if (descriptor < 0)
{
throw new Win32Exception(Marshal.GetLastPInvokeError(), "Could not duplicate a tray stream.");
}
return new SafeFileHandle(descriptor, ownsHandle: true);
}
internal static ProcessStartInfo CreateStartInfo(TrayOptions options)
{
if (options.BundleRoot is null || options.SmokeSeconds is not null)
{
throw new ArgumentException("Packaged start requires --bundle-root and does not support smoke mode.");
}
var executable = Path.Combine(options.BundleRoot, "tray", "aspire-tray");
if (!File.Exists(executable))
{
throw new FileNotFoundException("The Linux tray payload is missing.", executable);
}
var info = new ProcessStartInfo(executable)
{
UseShellExecute = false,
WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)
};
foreach (var argument in new[] { "--detached", "--cli", options.CliPath, "--bundle-root", options.BundleRoot })
{
info.ArgumentList.Add(argument);
}
if (options.StartupCliPath is { } startup)
{
info.ArgumentList.Add("--startup-cli");
info.ArgumentList.Add(startup);
}
return info;
}
internal static async Task StartAsync(TrayOptions options)
{
var startInfo = CreateStartInfo(options);
using var interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, context => context.Cancel = true);
using var terminate = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => context.Cancel = true);
using var lease = BundleVersionLease.Acquire(options.BundleRoot!, "tray-launcher", "tray start");
bool running;
using (var probe = TryAcquire(DirectoryPath))
{
running = probe is null;
}
if (running)
{
await TrayActivation.ShowExistingAsync(PipeName, CancellationToken.None).ConfigureAwait(false);
return;
}
using var child = Process.Start(startInfo) ?? throw new InvalidOperationException("Could not launch the tray.");
using var cancellation = new CancellationTokenSource();
var ready = TrayActivation.WaitUntilReadyAsync(PipeName, cancellation.Token);
try
{
var exited = child.WaitForExitAsync(cancellation.Token);
if (await Task.WhenAny(ready, exited).ConfigureAwait(false) == exited && child.ExitCode != 0)
{
throw new InvalidOperationException($"The tray exited during startup. See {LogPath}.");
}
await ready.ConfigureAwait(false);
}
catch (Exception ex)
{
if (!child.HasExited)
{
try
{
// A rejected readiness reply can reach us before Program finishes
// disposing discovery and its lease. Let that normal cleanup finish.
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
}
catch (TimeoutException)
{
// Only this newly launched child is ours, never an arbitrary tree
// that might already include a user-started AppHost.
child.Kill();
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
}
throw new InvalidOperationException($"The Linux tray did not become ready. A graphical session, GTK 3 and an AppIndicator library are required. See {LogPath}.", ex);
}
finally
{
await cancellation.CancelAsync().ConfigureAwait(false);
try
{
await ready.ConfigureAwait(false);
}
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException or IOException or InvalidOperationException)
{
// Observe a pending readiness task after the launch failure was reported.
}
}
}
internal static async Task StopAsync()
{
using (var probe = TryAcquire(DirectoryPath))
{
if (probe is not null)
{
return;
}
}
TrayProcessIdentity identity;
try
{
identity = await TrayActivation.StopExistingAsync(PipeName, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or TimeoutException)
{
using var probe = TryAcquire(DirectoryPath);
if (probe is null)
{
throw;
}
return;
}
using var timeout = new CancellationTokenSource(TrayActivation.RequestTimeout);
try
{
await identity.WaitForExitAsync(timeout.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
{
throw new TimeoutException("The tray accepted the stop request but has not exited.");
}
}
[LibraryImport("libc", SetLastError = true)] private static partial int setsid();
[LibraryImport("libc", SetLastError = true)] private static partial int flock(SafeFileHandle handle, int operation);
[LibraryImport("libc", SetLastError = true)] private static partial int dup2(SafeFileHandle handle, int destination);
[LibraryImport("libc", SetLastError = true)] private static partial int fchmod(SafeFileHandle handle, uint mode);
[LibraryImport("libc", SetLastError = true)] private static partial int fcntl(SafeFileHandle handle, int command, int argument);
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] private static partial int open(string path, int flags, uint mode);
}