feat: complete TASK-WP0-07 supervisor

This commit is contained in:
suyx
2026-07-27 23:28:36 +08:00
parent 2dd6f5c2c2
commit 096a1f1279
22 changed files with 1717 additions and 6 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Dada.Supervisor.Tests")]
+175
View File
@@ -0,0 +1,175 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace Dada.Supervisor;
internal enum ChildRole
{
Api,
Worker,
}
internal static class CredentialCatalog
{
internal const string ApiResend = "Dada/P0A/api/resend";
internal const string ApiAmap = "Dada/P0A/api/amap";
internal const string WorkerAiGateway = "Dada/P0A/worker/ai-gateway";
internal const string AdminPepper = "Dada/P0A/admin/pepper";
internal static IReadOnlyList<string> RequiredFor(ChildRole role) => role switch
{
ChildRole.Api => [ApiResend, ApiAmap],
ChildRole.Worker => [WorkerAiGateway],
_ => throw new ArgumentOutOfRangeException(nameof(role)),
};
}
internal interface ICredentialStore
{
string? Read(string target);
void Write(string target, string value);
void Delete(string target);
bool IsConfigured(string target);
}
internal sealed class MissingCredentialException(string target)
: InvalidOperationException($"Required credential is not configured: {target}");
internal static class CredentialProcessLauncher
{
internal static async Task<Process> StartAsync(
ProcessStartInfo startInfo,
ChildRole role,
ICredentialStore store,
CancellationToken cancellationToken = default)
{
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var target in CredentialCatalog.RequiredFor(role))
{
credentials[target] = store.Read(target) ?? throw new MissingCredentialException(target);
}
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.RedirectStandardInput = true;
var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start managed child process.");
var payload = JsonSerializer.SerializeToUtf8Bytes(credentials);
try
{
await process.StandardInput.BaseStream.WriteAsync(payload, cancellationToken);
await process.StandardInput.BaseStream.FlushAsync(cancellationToken);
}
catch
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
process.Dispose();
throw;
}
finally
{
Array.Clear(payload);
process.StandardInput.Close();
foreach (var target in credentials.Keys.ToArray()) credentials[target] = string.Empty;
credentials.Clear();
}
return process;
}
}
internal sealed class WindowsCredentialStore : ICredentialStore
{
public string? Read(string target)
{
if (!CredRead(target, CredentialType.Generic, 0, out var pointer))
{
var error = Marshal.GetLastWin32Error();
if (error == ErrorNotFound) return null;
throw new Win32Exception(error);
}
try
{
var credential = Marshal.PtrToStructure<NativeCredential>(pointer);
return credential.CredentialBlobSize == 0
? string.Empty
: Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / sizeof(char));
}
finally
{
CredFree(pointer);
}
}
public void Write(string target, string value)
{
var bytes = System.Text.Encoding.Unicode.GetBytes(value);
if (bytes.Length > MaximumGenericBlobSize) throw new ArgumentOutOfRangeException(nameof(value));
var pointer = Marshal.AllocHGlobal(bytes.Length);
try
{
Marshal.Copy(bytes, 0, pointer, bytes.Length);
var credential = new NativeCredential
{
Type = CredentialType.Generic,
TargetName = target,
CredentialBlobSize = bytes.Length,
CredentialBlob = pointer,
Persist = CredentialPersistence.LocalMachine,
UserName = Environment.UserName,
};
if (!CredWrite(ref credential, 0)) throw new Win32Exception(Marshal.GetLastWin32Error());
}
finally
{
Marshal.Copy(new byte[bytes.Length], 0, pointer, bytes.Length);
Marshal.FreeHGlobal(pointer);
Array.Clear(bytes);
}
}
public void Delete(string target)
{
if (CredDelete(target, CredentialType.Generic, 0)) return;
var error = Marshal.GetLastWin32Error();
if (error != ErrorNotFound) throw new Win32Exception(error);
}
public bool IsConfigured(string target) => Read(target) is not null;
private const int ErrorNotFound = 1168;
private const int MaximumGenericBlobSize = 5 * 512;
[DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredRead(string target, CredentialType type, int flags, out IntPtr credential);
[DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredWrite([In] ref NativeCredential credential, int flags);
[DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CredDelete(string target, CredentialType type, int flags);
[DllImport("Advapi32.dll")]
private static extern void CredFree(IntPtr buffer);
private enum CredentialType : uint { Generic = 1 }
private enum CredentialPersistence : uint { LocalMachine = 2 }
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NativeCredential
{
public uint Flags;
public CredentialType Type;
public string TargetName;
public string? Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public CredentialPersistence Persist;
public int AttributeCount;
public IntPtr Attributes;
public string? TargetAlias;
public string UserName;
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace Dada.Supervisor;
internal enum DiagnosticResult
{
Pass,
Warning,
Fail,
}
internal sealed record DiagnosticCheck(string CheckCode, DiagnosticResult Result, string MessageKey, DateTimeOffset CheckedAt)
{
internal static DiagnosticCheck Pass(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Pass, messageKey, DateTimeOffset.UtcNow);
internal static DiagnosticCheck Warning(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Warning, messageKey, DateTimeOffset.UtcNow);
internal static DiagnosticCheck Fail(string checkCode, string messageKey) => new(checkCode, DiagnosticResult.Fail, messageKey, DateTimeOffset.UtcNow);
}
internal sealed record DiagnosticStorageSummary(long UsedBytes, long CapacityBytes, long ReclaimableBytes);
internal sealed record CredentialStatus(string Service, bool Configured);
internal sealed record DiagnosticReport(
string AppVersion,
SupervisorState SupervisorState,
string BindHost,
int FixedPort,
IReadOnlyList<DiagnosticCheck> Checks,
DiagnosticStorageSummary Storage,
IReadOnlyList<CredentialStatus> Credentials)
{
internal static DiagnosticReport Create(
SupervisorState supervisorState,
IReadOnlyList<DiagnosticCheck> checks,
DiagnosticStorageSummary storage,
IReadOnlyList<CredentialStatus> credentials) =>
new(typeof(DiagnosticReport).Assembly.GetName().Version?.ToString() ?? "0.0.0", supervisorState, LoopbackEndpoint.Host, LoopbackEndpoint.Port, checks, storage, credentials);
}
@@ -0,0 +1,21 @@
using System.Net;
using System.Net.Sockets;
namespace Dada.Supervisor;
internal static class LoopbackPortGuard
{
internal static SupervisorState Check()
{
try
{
using var listener = new TcpListener(IPAddress.Parse(LoopbackEndpoint.Host), LoopbackEndpoint.Port);
listener.Start();
return SupervisorState.Starting;
}
catch (SocketException)
{
return SupervisorState.PortInUse;
}
}
}
@@ -0,0 +1,137 @@
using System.Diagnostics;
using System.IO.Pipes;
using System.Text;
namespace Dada.Supervisor;
internal sealed class ManagedChildProcess : IAsyncDisposable
{
private readonly NamedPipeServerStream controlPipe;
private readonly StreamWriter controlWriter;
private bool stopping;
private ManagedChildProcess(Process process, NamedPipeServerStream controlPipe, StreamWriter controlWriter)
{
Process = process;
this.controlPipe = controlPipe;
this.controlWriter = controlWriter;
}
internal Process Process { get; }
internal bool IsStopping => stopping;
internal static TimeSpan ShutdownDeadline { get; } = TimeSpan.FromSeconds(15);
internal static async Task<ManagedChildProcess> StartAsync(
ProcessStartInfo startInfo,
ChildRole role,
ICredentialStore credentialStore,
CancellationToken cancellationToken = default)
{
var pipeName = $"Dada.P0A.Control.{role}.{Guid.NewGuid():N}";
var pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.InOut,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
startInfo.ArgumentList.Add("--dada-control-pipe");
startInfo.ArgumentList.Add(pipeName);
startInfo.ArgumentList.Add("--dada-credential-stdin");
Process? process = null;
try
{
process = await CredentialProcessLauncher.StartAsync(startInfo, role, credentialStore, cancellationToken);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(10));
await pipe.WaitForConnectionAsync(timeout.Token);
var reader = new StreamReader(pipe, Encoding.UTF8, false, leaveOpen: true);
var ready = await reader.ReadLineAsync(timeout.Token);
if (!string.Equals(ready, "ready", StringComparison.Ordinal)) throw new InvalidOperationException("Managed child did not report ready.");
var writer = new StreamWriter(pipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
return new ManagedChildProcess(process, pipe, writer);
}
catch
{
pipe.Dispose();
if (process is { HasExited: false }) process.Kill(entireProcessTree: true);
process?.Dispose();
throw;
}
}
internal async Task StopAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
if (stopping || Process.HasExited) return;
stopping = true;
await controlWriter.WriteLineAsync("shutdown".AsMemory(), cancellationToken);
var exited = Process.WaitForExitAsync(cancellationToken);
var deadline = Task.Delay(timeout ?? ShutdownDeadline, cancellationToken);
if (await Task.WhenAny(exited, deadline) != exited && !Process.HasExited)
{
Process.Kill(entireProcessTree: true);
await Process.WaitForExitAsync(cancellationToken);
}
}
public async ValueTask DisposeAsync()
{
if (!Process.HasExited) await StopAsync();
controlWriter.Dispose();
controlPipe.Dispose();
Process.Dispose();
}
}
internal sealed class ManagedComponentSupervisor : IAsyncDisposable
{
private readonly Func<CancellationToken, Task<ManagedChildProcess>> start;
private readonly SupervisorState degradedState;
private ManagedChildProcess? child;
private int completedRestarts;
private DateTimeOffset firstFailure;
private bool stopping;
internal ManagedComponentSupervisor(Func<CancellationToken, Task<ManagedChildProcess>> start, SupervisorState degradedState)
{
this.start = start;
this.degradedState = degradedState;
}
internal event Action<SupervisorState>? StateChanged;
internal async Task StartAsync(CancellationToken cancellationToken = default)
{
child = await start(cancellationToken);
child.Process.EnableRaisingEvents = true;
child.Process.Exited += OnChildExited;
}
private async void OnChildExited(object? sender, EventArgs eventArgs)
{
if (stopping || child?.IsStopping == true) return;
if (completedRestarts == 0) firstFailure = DateTimeOffset.UtcNow;
if (!RestartPolicy.CanRestart(completedRestarts, DateTimeOffset.UtcNow - firstFailure))
{
StateChanged?.Invoke(degradedState);
return;
}
var delay = RestartPolicy.Delays[completedRestarts++];
await Task.Delay(delay);
try
{
child = await start(CancellationToken.None);
child.Process.EnableRaisingEvents = true;
child.Process.Exited += OnChildExited;
}
catch
{
OnChildExited(null, EventArgs.Empty);
}
}
public async ValueTask DisposeAsync()
{
stopping = true;
if (child is not null) await child.DisposeAsync();
}
}
@@ -0,0 +1,216 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Dada.Supervisor;
internal static class OfflineCommandRouter
{
private const string MutexName = "Dada.P0A.Instance";
private const string OpenPipeName = "Dada.P0A.Open";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true };
internal static async Task<int> RunAsync(string[] args, ICredentialStore credentials)
{
var modifying = IsModifying(args);
using var instance = modifying ? await SingleInstanceCoordinator.TryAcquireAsync(MutexName, OpenPipeName) : null;
if (instance is { IsPrimary: false })
{
WriteResult("instance_running", false);
return 3;
}
try
{
return args[0] switch
{
"configure" => RunConfigure(args.Skip(1).ToArray()),
"secrets" => RunSecrets(args.Skip(1).ToArray(), credentials),
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
"doctor" when args.Length == 1 => RunDoctor(credentials),
_ => Usage(),
};
}
catch (Exception exception) when (exception is ArgumentException or InvalidOperationException or IOException or UnauthorizedAccessException)
{
WriteResult(exception.Message, false);
return 2;
}
}
private static int RunConfigure(string[] args)
{
var store = new InstanceConfigurationStore();
var current = store.Load();
InstanceConfiguration next;
if (args is ["init", var initialDataRoot, var initialAssetRoot])
{
next = new InstanceConfiguration(current.Revision + 1, ValidateDataRoot(initialDataRoot), ValidateAssetRoot(initialAssetRoot), current.AdminAllowlistHashes);
}
else if (args is ["data-root", var updatedDataRoot])
{
next = current with { Revision = current.Revision + 1, LocalDataRoot = ValidateDataRoot(updatedDataRoot) };
}
else if (args is ["asset-root", var updatedAssetRoot])
{
next = current with { Revision = current.Revision + 1, AssetRoot = ValidateAssetRoot(updatedAssetRoot) };
}
else
{
return Usage();
}
store.Save(next);
WriteResult("configuration_updated", true, next.Revision);
return 0;
}
private static int RunSecrets(string[] args, ICredentialStore store)
{
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
switch (args[0])
{
case "status":
WriteResult(store.IsConfigured(target) ? "configured" : "not_configured", true);
return 0;
case "clear":
store.Delete(target);
WriteResult("credential_cleared", true);
return 0;
case "set":
var value = ReadHiddenValue();
if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("credential_value_required");
store.Write(target, value);
WriteResult("credential_saved", true);
return 0;
default:
return Usage();
}
}
private static int RunAdminAllowlist(string[] args, ICredentialStore credentials)
{
var configStore = new InstanceConfigurationStore();
var current = configStore.Load();
if (args is ["status"])
{
WriteResult("allowlist_status", true, current.AdminAllowlistHashes.Count);
return 0;
}
if (args is not [var action, var email] || action is not ("add" or "remove")) return Usage();
var pepper = credentials.Read(CredentialCatalog.AdminPepper) ?? throw new InvalidOperationException("admin_pepper_not_configured");
var normalized = email.Trim().ToLowerInvariant();
if (!normalized.Contains('@') || normalized.Length > 254) throw new ArgumentException("invalid_email");
var digest = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(pepper), Encoding.UTF8.GetBytes(normalized)));
var hashes = current.AdminAllowlistHashes.ToHashSet(StringComparer.Ordinal);
if (action == "add") hashes.Add(digest); else hashes.Remove(digest);
configStore.Save(current with { Revision = current.Revision + 1, AdminAllowlistHashes = hashes.Order().ToArray() });
WriteResult(action == "add" ? "allowlist_entry_added" : "allowlist_entry_removed", true, hashes.Count);
return 0;
}
private static int RunDoctor(ICredentialStore credentials)
{
var report = DiagnosticReport.Create(
LoopbackPortGuard.Check(),
[DiagnosticCheck.Pass("bind_host", "loopback_only"), DiagnosticCheck.Pass("fixed_port", "port_checked")],
new DiagnosticStorageSummary(0, 0, 0),
[
new CredentialStatus("api_resend", credentials.IsConfigured(CredentialCatalog.ApiResend)),
new CredentialStatus("api_amap", credentials.IsConfigured(CredentialCatalog.ApiAmap)),
new CredentialStatus("worker_ai_gateway", credentials.IsConfigured(CredentialCatalog.WorkerAiGateway)),
]);
Console.WriteLine(JsonSerializer.Serialize(report, JsonOptions));
return 0;
}
private static string ValidateDataRoot(string path)
{
var fullPath = ValidateDirectory(path);
var probe = Path.Combine(fullPath, $".dada-write-{Guid.NewGuid():N}.tmp");
using (File.Create(probe)) { }
File.Delete(probe);
return fullPath;
}
private static string ValidateAssetRoot(string path) => ValidateDirectory(path);
private static string ValidateDirectory(string path)
{
var fullPath = Path.GetFullPath(path);
if (!Directory.Exists(fullPath)) throw new ArgumentException("directory_not_found");
return fullPath;
}
private static bool TryResolveCredential(string alias, out string target)
{
target = alias switch
{
"api-resend" => CredentialCatalog.ApiResend,
"api-amap" => CredentialCatalog.ApiAmap,
"worker-ai-gateway" => CredentialCatalog.WorkerAiGateway,
"admin-pepper" => CredentialCatalog.AdminPepper,
_ => string.Empty,
};
return target.Length > 0;
}
private static string ReadHiddenValue()
{
if (Console.IsInputRedirected) return Console.ReadLine() ?? string.Empty;
var value = new StringBuilder();
while (true)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter) break;
if (key.Key == ConsoleKey.Backspace && value.Length > 0) value.Length--;
else if (!char.IsControl(key.KeyChar)) value.Append(key.KeyChar);
}
Console.WriteLine();
return value.ToString();
}
private static bool IsModifying(string[] args) => args.FirstOrDefault() switch
{
"configure" => true,
"secrets" => args.ElementAtOrDefault(1) is "set" or "clear",
"admin-allowlist" => args.ElementAtOrDefault(1) is "add" or "remove",
_ => false,
};
private static void WriteResult(string code, bool success, int? revisionOrCount = null) =>
Console.WriteLine(JsonSerializer.Serialize(new { code, revision_or_count = revisionOrCount, success }, JsonOptions));
private static int Usage()
{
WriteResult("usage: configure init|data-root|asset-root; secrets set|status|clear; admin-allowlist add|remove|status; doctor", false);
return 2;
}
}
internal sealed record InstanceConfiguration(int Revision, string? LocalDataRoot, string? AssetRoot, IReadOnlyList<string> AdminAllowlistHashes)
{
internal static InstanceConfiguration Empty { get; } = new(0, null, null, []);
}
internal sealed class InstanceConfigurationStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true };
private readonly string path;
internal InstanceConfigurationStore(string? path = null)
{
this.path = path ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Dada", "P0A", "config", "instance.json");
}
internal InstanceConfiguration Load() => File.Exists(path)
? JsonSerializer.Deserialize<InstanceConfiguration>(File.ReadAllText(path), JsonOptions) ?? InstanceConfiguration.Empty
: InstanceConfiguration.Empty;
internal void Save(InstanceConfiguration configuration)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temporary = path + ".tmp";
File.WriteAllText(temporary, JsonSerializer.Serialize(configuration, JsonOptions) + Environment.NewLine);
File.Move(temporary, path, overwrite: true);
}
}
+43 -2
View File
@@ -2,10 +2,51 @@ namespace Dada.Supervisor;
internal static class Program
{
private const string MutexName = "Dada.P0A.Instance";
private const string OpenPipeName = "Dada.P0A.Open";
[STAThread]
private static void Main()
private static async Task Main(string[] args)
{
if (args.Length > 0)
{
Environment.ExitCode = await OfflineCommandRouter.RunAsync(args, new WindowsCredentialStore());
return;
}
using var instance = await SingleInstanceCoordinator.TryAcquireAsync(MutexName, OpenPipeName);
if (!instance.IsPrimary) return;
ApplicationConfiguration.Initialize();
Application.Run(new SupervisorForm());
using var form = new SupervisorForm(LoopbackPortGuard.Check());
SupervisorRuntime? runtime = null;
async Task StartRuntimeAsync()
{
if (runtime is not null) await runtime.DisposeAsync();
runtime = new SupervisorRuntime(new WindowsCredentialStore());
runtime.StateChanged += state =>
{
if (!form.IsDisposed) form.BeginInvoke(() => form.SetState(state));
};
var state = await runtime.StartAsync();
if (!form.IsDisposed) form.SetState(state);
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
}
form.Shown += async (_, _) => await StartRuntimeAsync();
form.RestartRequested += async () => await StartRuntimeAsync();
_ = ListenForOpenRequestAsync(instance, form);
Application.Run(form);
if (runtime is not null) await runtime.DisposeAsync();
}
private static async Task ListenForOpenRequestAsync(SingleInstanceCoordinator instance, SupervisorForm form)
{
while (!form.IsDisposed)
{
if (await instance.WaitForOpenRequestAsync(TimeSpan.FromSeconds(30)) && !form.IsDisposed)
{
form.BeginInvoke(form.RestoreWindow);
}
}
}
}
@@ -0,0 +1,91 @@
using System.IO.Pipes;
namespace Dada.Supervisor;
internal sealed class SingleInstanceCoordinator : IDisposable
{
private readonly string pipeName;
private readonly ManualResetEventSlim? releaseOwner;
private readonly Thread? ownerThread;
private bool disposed;
private SingleInstanceCoordinator(string pipeName, bool isPrimary, ManualResetEventSlim? releaseOwner, Thread? ownerThread)
{
this.pipeName = pipeName;
IsPrimary = isPrimary;
this.releaseOwner = releaseOwner;
this.ownerThread = ownerThread;
}
internal bool IsPrimary { get; }
internal static async Task<SingleInstanceCoordinator> TryAcquireAsync(string mutexName, string pipeName)
{
var acquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new ManualResetEventSlim(false);
var owner = new Thread(() =>
{
using var mutex = new Mutex(initiallyOwned: false, mutexName);
var ownsMutex = false;
try
{
ownsMutex = mutex.WaitOne(0);
acquired.SetResult(ownsMutex);
if (ownsMutex) release.Wait();
}
catch (AbandonedMutexException)
{
ownsMutex = true;
acquired.SetResult(true);
release.Wait();
}
finally
{
if (ownsMutex) mutex.ReleaseMutex();
}
}) { IsBackground = true, Name = "Dada instance mutex" };
owner.Start();
var isPrimary = await acquired.Task;
if (isPrimary) return new SingleInstanceCoordinator(pipeName, true, release, owner);
release.Dispose();
owner.Join();
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, PipeOptions.Asynchronous);
try
{
await client.ConnectAsync(2000);
await client.WriteAsync(new byte[] { 1 });
await client.FlushAsync();
}
catch (TimeoutException)
{
// The existing process can still be starting; the second process must exit either way.
}
return new SingleInstanceCoordinator(pipeName, false, null, null);
}
internal async Task<bool> WaitForOpenRequestAsync(TimeSpan timeout)
{
if (!IsPrimary) return false;
using var cancellation = new CancellationTokenSource(timeout);
await using var server = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
try
{
await server.WaitForConnectionAsync(cancellation.Token);
return server.ReadByte() == 1;
}
catch (OperationCanceledException)
{
return false;
}
}
public void Dispose()
{
if (disposed) return;
disposed = true;
releaseOwner?.Set();
ownerThread?.Join(TimeSpan.FromSeconds(2));
releaseOwner?.Dispose();
}
}
+295 -2
View File
@@ -1,13 +1,306 @@
using System.Diagnostics;
namespace Dada.Supervisor;
internal sealed class SupervisorForm : Form
{
public SupervisorForm()
private readonly NotifyIcon trayIcon;
private readonly Label statusTitle;
private readonly Label statusDetail;
private readonly Label apiStatus;
private readonly Label workerStatus;
private readonly Label storageStatus;
private readonly FlowLayoutPanel actions;
private readonly ToolStripMenuItem openMenuItem;
private readonly ToolStripMenuItem diagnosticsMenuItem;
private readonly ToolStripMenuItem restartMenuItem;
private readonly string correlationId = $"SUP-{Guid.NewGuid():N}"[..12].ToUpperInvariant();
private SupervisorState state;
internal SupervisorForm(SupervisorState initialState = SupervisorState.Starting)
{
ClientSize = new Size(420, 160);
state = initialState;
AutoScaleMode = AutoScaleMode.Dpi;
BackColor = Color.FromArgb(246, 247, 249);
ClientSize = new Size(480, 480);
Font = new Font("Segoe UI", 9F);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
StartPosition = FormStartPosition.CenterScreen;
Text = "Dada";
var header = new Panel { BackColor = Color.FromArgb(24, 28, 34), Dock = DockStyle.Top, Height = 126, Padding = new Padding(28, 20, 28, 16) };
var brand = new Label { AutoSize = true, Font = new Font("Segoe UI Semibold", 12F), ForeColor = Color.White, Location = new Point(28, 18), Text = "DADA" };
statusTitle = new Label { AutoEllipsis = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), ForeColor = Color.White, Location = new Point(28, 49), Size = new Size(420, 32) };
statusDetail = new Label { Font = new Font("Microsoft YaHei UI", 9F), ForeColor = Color.FromArgb(181, 188, 198), Location = new Point(28, 82), Size = new Size(420, 38) };
header.Controls.AddRange([brand, statusTitle, statusDetail]);
var componentPanel = new TableLayoutPanel
{
BackColor = Color.White,
ColumnCount = 2,
Dock = DockStyle.Top,
Height = 172,
Padding = new Padding(28, 20, 28, 12),
RowCount = 3,
};
componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 62));
componentPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 38));
componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33));
componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 33));
componentPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 34));
apiStatus = AddStatusRow(componentPanel, 0, "本机 API", ApiStatus(initialState));
workerStatus = AddStatusRow(componentPanel, 1, "后台 Worker", WorkerStatus(initialState));
storageStatus = AddStatusRow(componentPanel, 2, "本地数据", StorageStatus(initialState));
actions = new FlowLayoutPanel
{
BackColor = Color.FromArgb(246, 247, 249),
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
Padding = new Padding(24, 24, 20, 16),
WrapContents = true,
};
Controls.Add(actions);
Controls.Add(componentPanel);
Controls.Add(header);
var menu = new ContextMenuStrip();
openMenuItem = new ToolStripMenuItem("打开 Dada", null, (_, _) => OpenProduct());
menu.Items.Add(openMenuItem);
menu.Items.Add("运行状态", null, (_, _) => RestoreWindow());
diagnosticsMenuItem = new ToolStripMenuItem("打开诊断", null, (_, _) => ShowDiagnostics());
menu.Items.Add(diagnosticsMenuItem);
restartMenuItem = new ToolStripMenuItem("重新启动服务", null, (_, _) =>
{
SetState(SupervisorState.Starting);
RestartRequested?.Invoke();
});
menu.Items.Add(restartMenuItem);
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add("退出 Dada", null, (_, _) => Close());
trayIcon = new NotifyIcon
{
ContextMenuStrip = menu,
Icon = SystemIcons.Application,
Text = TrayText(initialState),
Visible = !SystemInformation.UserInteractive ? false : true,
};
trayIcon.DoubleClick += (_, _) => RestoreWindow();
FormClosing += (_, _) => trayIcon.Visible = false;
Resize += (_, _) =>
{
if (WindowState == FormWindowState.Minimized) Hide();
};
SetState(initialState);
}
internal event Action? RestartRequested;
internal IReadOnlyList<string> TrayMenuLabels => trayIcon.ContextMenuStrip?.Items
.OfType<ToolStripMenuItem>()
.Select(item => item.Text ?? string.Empty)
.ToArray() ?? [];
internal void SetState(SupervisorState nextState)
{
state = nextState;
(statusTitle.Text, statusDetail.Text) = nextState switch
{
SupervisorState.Starting => ("正在启动本机服务", "请稍候,Dada 正在检查 API、Worker 和本地数据。"),
SupervisorState.Ready => ("Dada 已就绪", "本机服务运行正常。"),
SupervisorState.StartupFailed => ("Dada 启动失败", $"服务未能启动 · {DateTimeOffset.Now:HH:mm:ss} · 关联 ID {correlationId}\n诊断信息已移除敏感内容。"),
SupervisorState.PortInUse => ("Dada 无法使用固定端口启动", "固定端口 43121 已被占用,Dada 不会改用其他端口。"),
SupervisorState.ApiDegraded => ("本机 API 不可用", "网页暂时无法使用,可打开诊断或重新启动服务。"),
SupervisorState.WorkerDegraded => ("后台服务暂时不可用", "可继续查看、下载、编辑和删除;暂不能创建新任务。"),
SupervisorState.StorageFull => ("本地存储空间已满", "可继续查看和导出;清理空间后再创建新内容。"),
SupervisorState.StorageUnavailable => ("本地数据暂时不可用", "请恢复已配置的数据目录,然后重试。"),
SupervisorState.DiagnosticsRunning => ("正在运行诊断", "正在检查本机服务状态。"),
SupervisorState.DiagnosticsReady => ("诊断已完成", "可复制已脱敏的诊断结果。"),
_ => throw new ArgumentOutOfRangeException(nameof(nextState)),
};
trayIcon.Text = TrayText(nextState);
SetStatusLabel(apiStatus, ApiStatus(nextState));
SetStatusLabel(workerStatus, WorkerStatus(nextState));
SetStatusLabel(storageStatus, StorageStatus(nextState));
var allowed = SupervisorActions.For(nextState);
openMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenProduct);
diagnosticsMenuItem.Enabled = allowed.Contains(SupervisorAction.OpenDiagnostics);
restartMenuItem.Visible = allowed.Contains(SupervisorAction.RestartServices);
RenderActions();
}
internal void RestoreWindow()
{
Show();
WindowState = FormWindowState.Normal;
Activate();
}
protected override void Dispose(bool disposing)
{
if (disposing) trayIcon.Dispose();
base.Dispose(disposing);
}
private void RenderActions()
{
actions.SuspendLayout();
actions.Controls.Clear();
foreach (var action in SupervisorActions.For(state))
{
var button = new Button
{
AutoSize = false,
BackColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.FromArgb(28, 32, 38) : Color.White,
FlatStyle = FlatStyle.Flat,
ForeColor = action is SupervisorAction.Retry or SupervisorAction.OpenProduct ? Color.White : Color.FromArgb(33, 37, 43),
Height = 38,
Margin = new Padding(4),
Text = ActionLabel(action),
Width = action switch
{
SupervisorAction.OpenChrome => 132,
SupervisorAction.OpenEdge => 122,
SupervisorAction.OpenDiagnostics => 122,
SupervisorAction.CheckExistingInstance => 132,
SupervisorAction.RestartServices => 122,
_ => 112,
},
};
button.FlatAppearance.BorderColor = Color.FromArgb(215, 219, 225);
button.Click += (_, _) => InvokeAction(action);
actions.Controls.Add(button);
}
actions.ResumeLayout();
}
private void InvokeAction(SupervisorAction action)
{
switch (action)
{
case SupervisorAction.OpenProduct:
OpenProduct();
break;
case SupervisorAction.OpenChrome:
SupportedBrowserLauncher.OpenChrome(LoopbackEndpoint.ProductUri);
break;
case SupervisorAction.OpenEdge:
SupportedBrowserLauncher.OpenEdge(LoopbackEndpoint.ProductUri);
break;
case SupervisorAction.OpenDiagnostics:
case SupervisorAction.CheckExistingInstance:
ShowDiagnostics();
break;
case SupervisorAction.Retry:
case SupervisorAction.RestartServices:
SetState(SupervisorState.Starting);
RestartRequested?.Invoke();
break;
case SupervisorAction.Exit:
Close();
break;
}
}
internal static bool OpenProductInSupportedBrowser() => SupportedBrowserLauncher.OpenDefaultSupported(LoopbackEndpoint.ProductUri);
private static void OpenProduct() => OpenProductInSupportedBrowser();
private void ShowDiagnostics()
{
using var diagnostics = new DiagnosticsForm(state);
diagnostics.ShowDialog(this);
}
private static Label AddStatusRow(TableLayoutPanel panel, int row, string label, string status)
{
panel.Controls.Add(new Label { Anchor = AnchorStyles.Left, AutoSize = true, Font = new Font("Microsoft YaHei UI", 10F), Text = label }, 0, row);
var statusLabel = new Label { Anchor = AnchorStyles.Right, AutoSize = true, Font = new Font("Microsoft YaHei UI", 9F), Text = status };
SetStatusLabel(statusLabel, status);
panel.Controls.Add(statusLabel, 1, row);
return statusLabel;
}
private static void SetStatusLabel(Label label, string status)
{
label.Text = status;
label.ForeColor = status is "可用" or "正常" ? Color.FromArgb(28, 122, 76) : Color.FromArgb(180, 86, 32);
}
private static string ApiStatus(SupervisorState value) => value is SupervisorState.ApiDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常";
private static string WorkerStatus(SupervisorState value) => value is SupervisorState.WorkerDegraded or SupervisorState.StartupFailed or SupervisorState.PortInUse ? "不可用" : value == SupervisorState.Starting ? "启动中" : "正常";
private static string TrayText(SupervisorState value) => value switch
{
SupervisorState.Starting => "Dada - 正在启动",
SupervisorState.Ready => "Dada - 运行正常",
SupervisorState.ApiDegraded => "Dada - API 不可用",
SupervisorState.WorkerDegraded => "Dada - Worker 不可用",
SupervisorState.StorageFull => "Dada - 存储空间已满",
SupervisorState.StorageUnavailable => "Dada - 本地数据不可用",
SupervisorState.PortInUse => "Dada - 固定端口被占用",
SupervisorState.StartupFailed => "Dada - 启动失败",
_ => "Dada - 运行诊断",
};
private static string StorageStatus(SupervisorState value) => value switch
{
SupervisorState.StorageFull => "已满",
SupervisorState.StorageUnavailable => "不可用",
_ => "可用",
};
private static string ActionLabel(SupervisorAction action) => action switch
{
SupervisorAction.OpenProduct => "打开 Dada",
SupervisorAction.OpenChrome => "使用 Chrome 打开",
SupervisorAction.OpenEdge => "使用 Edge 打开",
SupervisorAction.OpenDiagnostics => "打开诊断",
SupervisorAction.CheckExistingInstance => "检查已有 Dada",
SupervisorAction.Retry => "重试",
SupervisorAction.RestartServices => "重新启动服务",
SupervisorAction.Exit => "退出 Dada",
_ => throw new ArgumentOutOfRangeException(nameof(action)),
};
}
internal sealed class DiagnosticsForm : Form
{
internal DiagnosticsForm(SupervisorState state)
{
BackColor = Color.White;
ClientSize = new Size(760, 440);
Font = new Font("Microsoft YaHei UI", 9F);
StartPosition = FormStartPosition.CenterParent;
Text = "Dada 运行诊断";
var title = new Label { AutoSize = true, Font = new Font("Microsoft YaHei UI", 15F, FontStyle.Bold), Location = new Point(28, 24), Text = "运行诊断" };
var detail = new Label { ForeColor = Color.FromArgb(91, 97, 105), Location = new Point(30, 62), Size = new Size(700, 42), Text = "诊断仅包含服务状态、固定端口、目录可用性和凭据是否已配置,不包含凭据值或用户内容。" };
var checks = new ListView { Location = new Point(30, 116), Size = new Size(700, 250), View = View.Details, FullRowSelect = true };
checks.Columns.Add("组件", 170);
checks.Columns.Add("状态", 110);
checks.Columns.Add("检查结果", 390);
checks.Items.Add(new ListViewItem(["Supervisor", "正常", StateLabel(state)]));
checks.Items.Add(new ListViewItem(["固定端口", "43121", "仅绑定 127.0.0.1"]));
checks.Items.Add(new ListViewItem(["凭据", "已脱敏", "仅显示是否已配置"]));
var copy = new Button { Location = new Point(592, 382), Size = new Size(138, 36), Text = "复制脱敏结果" };
copy.Click += (_, _) => Clipboard.SetText("Dada diagnostics: redacted");
Controls.AddRange([title, detail, checks, copy]);
}
private static string StateLabel(SupervisorState state) => state switch
{
SupervisorState.Starting => "正在启动",
SupervisorState.Ready => "运行正常",
SupervisorState.StartupFailed => "启动失败",
SupervisorState.PortInUse => "固定端口被占用",
SupervisorState.ApiDegraded => "API 不可用",
SupervisorState.WorkerDegraded => "Worker 不可用",
SupervisorState.StorageFull => "存储空间已满",
SupervisorState.StorageUnavailable => "本地数据不可用",
SupervisorState.DiagnosticsRunning => "诊断中",
SupervisorState.DiagnosticsReady => "诊断完成",
_ => "未知",
};
}
@@ -0,0 +1,62 @@
using System.Diagnostics;
namespace Dada.Supervisor;
internal sealed class SupervisorRuntime : IAsyncDisposable
{
private readonly ICredentialStore credentials;
private ManagedComponentSupervisor? api;
private ManagedComponentSupervisor? worker;
internal SupervisorRuntime(ICredentialStore credentials)
{
this.credentials = credentials;
}
internal event Action<SupervisorState>? StateChanged;
internal async Task<SupervisorState> StartAsync(CancellationToken cancellationToken = default)
{
if (LoopbackPortGuard.Check() == SupervisorState.PortInUse) return SupervisorState.PortInUse;
var configuration = new InstanceConfigurationStore().Load();
if (configuration.LocalDataRoot is null || configuration.AssetRoot is null ||
!Directory.Exists(configuration.LocalDataRoot) || !Directory.Exists(configuration.AssetRoot))
{
return SupervisorState.StartupFailed;
}
if (CredentialCatalog.RequiredFor(ChildRole.Api).Concat(CredentialCatalog.RequiredFor(ChildRole.Worker)).Any(target => !credentials.IsConfigured(target)))
{
return SupervisorState.StartupFailed;
}
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
var apiEntry = Path.Combine(AppContext.BaseDirectory, "apps", "api", "dist", "main.js");
var workerEntry = Path.Combine(AppContext.BaseDirectory, "apps", "worker", "dist", "worker.js");
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
api.StateChanged += state => StateChanged?.Invoke(state);
await api.StartAsync(cancellationToken);
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
worker.StateChanged += state => StateChanged?.Invoke(state);
await worker.StartAsync(cancellationToken);
return SupervisorState.Ready;
}
private ManagedComponentSupervisor CreateComponent(string node, string entry, ChildRole role, SupervisorState degradedState) =>
new(async cancellationToken =>
{
var startInfo = new ProcessStartInfo(node);
startInfo.ArgumentList.Add(entry);
return await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken);
}, degradedState);
public async ValueTask DisposeAsync()
{
var stops = new List<Task>();
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
await Task.WhenAll(stops);
}
}
@@ -0,0 +1,54 @@
namespace Dada.Supervisor;
internal enum SupervisorState
{
Starting,
Ready,
StartupFailed,
PortInUse,
ApiDegraded,
WorkerDegraded,
StorageFull,
StorageUnavailable,
DiagnosticsRunning,
DiagnosticsReady,
}
internal enum SupervisorAction
{
OpenProduct,
OpenChrome,
OpenEdge,
OpenDiagnostics,
CheckExistingInstance,
Retry,
RestartServices,
Exit,
}
internal static class SupervisorActions
{
internal static IReadOnlyList<SupervisorAction> For(SupervisorState state) => state switch
{
SupervisorState.Starting => [SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
SupervisorState.Ready => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit],
SupervisorState.StartupFailed => [SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
SupervisorState.PortInUse => [SupervisorAction.CheckExistingInstance, SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
SupervisorState.ApiDegraded => [SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit],
SupervisorState.WorkerDegraded => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.RestartServices, SupervisorAction.Exit],
SupervisorState.StorageFull => [SupervisorAction.OpenProduct, SupervisorAction.OpenChrome, SupervisorAction.OpenEdge, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
SupervisorState.StorageUnavailable => [SupervisorAction.Retry, SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
SupervisorState.DiagnosticsRunning => [SupervisorAction.Exit],
SupervisorState.DiagnosticsReady => [SupervisorAction.OpenDiagnostics, SupervisorAction.Exit],
_ => throw new ArgumentOutOfRangeException(nameof(state)),
};
}
internal static class RestartPolicy
{
internal const int MaximumRestarts = 3;
internal static readonly IReadOnlyList<TimeSpan> Delays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)];
internal static bool CanRestart(int completedRestarts, TimeSpan failureWindow) =>
completedRestarts < MaximumRestarts && failureWindow <= TimeSpan.FromMinutes(5);
}
@@ -0,0 +1,33 @@
using System.Diagnostics;
using Microsoft.Win32;
namespace Dada.Supervisor;
internal static class SupportedBrowserLauncher
{
internal static bool OpenDefaultSupported(Uri uri) => Open("chrome.exe", uri) || Open("msedge.exe", uri);
internal static bool OpenChrome(Uri uri) => Open("chrome.exe", uri);
internal static bool OpenEdge(Uri uri) => Open("msedge.exe", uri);
private static bool Open(string executableName, Uri uri)
{
var executable = FindExecutable(executableName);
if (executable is null) return false;
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
startInfo.ArgumentList.Add(uri.AbsoluteUri);
Process.Start(startInfo);
return true;
}
private static string? FindExecutable(string executableName)
{
foreach (var hive in new[] { RegistryHive.CurrentUser, RegistryHive.LocalMachine })
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var key = baseKey.OpenSubKey($"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{executableName}");
if (key?.GetValue(null) is string path && File.Exists(path)) return path;
}
return null;
}
}