从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
535 lines
25 KiB
C#
535 lines
25 KiB
C#
using System.Diagnostics;
|
||
using System.Net.Sockets;
|
||
using System.Runtime.InteropServices;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace MiGu.Server.Launcher;
|
||
|
||
/// <summary>
|
||
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||
///
|
||
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||
/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程;
|
||
/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL。
|
||
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||
/// </summary>
|
||
public sealed class SimpleLiteLauncher : IDisposable
|
||
{
|
||
private readonly SimpleLiteOptions _opts;
|
||
private readonly ILogger<SimpleLiteLauncher> _log;
|
||
private readonly IHostEnvironment _env;
|
||
private readonly object _sync = new();
|
||
|
||
private Process? _proc;
|
||
private IntPtr _job = IntPtr.Zero;
|
||
private string? _lastLaunchMode;
|
||
|
||
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
|
||
{
|
||
_opts = opts.Value;
|
||
_log = log;
|
||
_env = env;
|
||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
||
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||
if (_opts.FollowParent)
|
||
{
|
||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||
}
|
||
}
|
||
|
||
public bool IsRunning
|
||
{
|
||
get
|
||
{
|
||
lock (_sync) return _proc is { HasExited: false };
|
||
}
|
||
}
|
||
|
||
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||
|
||
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||
internal const string ExternalReuseLaunchMode = "external";
|
||
|
||
/// <summary>
|
||
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
||
/// </summary>
|
||
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||
public LaunchResult MaybeStart(string launchMode)
|
||
{
|
||
var displayMode = NormalizeDisplayMode(launchMode);
|
||
|
||
if (!_opts.Enabled)
|
||
{
|
||
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
|
||
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
|
||
}
|
||
|
||
lock (_sync)
|
||
{
|
||
if (_proc is { HasExited: false })
|
||
{
|
||
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
|
||
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
|
||
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
||
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
|
||
: null;
|
||
return new LaunchResult(true, "AlreadyRunning",
|
||
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||
DisplayMode: _lastLaunchMode,
|
||
Warning: warning);
|
||
}
|
||
|
||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||
//
|
||
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
||
//
|
||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
||
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
|
||
{
|
||
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
|
||
if (!probeOk)
|
||
{
|
||
_log.LogWarning(
|
||
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
|
||
_opts.ProjectionPort);
|
||
return new LaunchResult(false, "PortOccupied",
|
||
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
||
DisplayMode: null,
|
||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
|
||
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
|
||
}
|
||
|
||
_log.LogInformation(
|
||
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
|
||
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
|
||
_opts.ProjectionPort);
|
||
|
||
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||
_lastLaunchMode = ExternalReuseLaunchMode;
|
||
|
||
return new LaunchResult(true, "ReusingExisting",
|
||
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
|
||
DisplayMode: ExternalReuseLaunchMode,
|
||
Warning: $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。");
|
||
}
|
||
|
||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||
if (resolved == null)
|
||
{
|
||
_log.LogWarning(
|
||
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
|
||
_env.ContentRootPath);
|
||
return new LaunchResult(false, "ExecutableNotFound",
|
||
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
|
||
DisplayMode: null,
|
||
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
|
||
}
|
||
|
||
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||
? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory
|
||
: Path.GetFullPath(_opts.WorkingDirectory);
|
||
|
||
var arguments = BuildArguments(displayMode, _opts.Arguments);
|
||
|
||
// 会话 N+2:把 SimpleLite 作为独立进程拉起,**带自己的控制台窗口**。
|
||
// - UseShellExecute=true:交给 OS Shell 启动,生成新的进程组 + 独立 console,
|
||
// MiGu.Server 关闭不会影响它(不再处于父进程的「controlling process」链上)。
|
||
// - CreateNoWindow=false + 不重定向 stdout/stderr:SimpleLite 自带 OutputType=Exe 控制台,
|
||
// 即便选 webonly 模式也会有一个黑底控制台显示日志,便于用户观察 / 关闭。
|
||
// - 不能与 RedirectStandard* 共用,要让日志独立就只能让用户看 SimpleLite 自己的窗口。
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = resolved,
|
||
Arguments = arguments,
|
||
WorkingDirectory = workdir,
|
||
UseShellExecute = true,
|
||
CreateNoWindow = false,
|
||
WindowStyle = ProcessWindowStyle.Normal,
|
||
};
|
||
|
||
try
|
||
{
|
||
var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||
proc.Exited += (_, _) =>
|
||
{
|
||
int exit;
|
||
try { exit = proc.ExitCode; } catch { exit = -1; }
|
||
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
|
||
lock (_sync)
|
||
{
|
||
if (ReferenceEquals(_proc, proc))
|
||
{
|
||
_proc = null;
|
||
_lastLaunchMode = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
if (!proc.Start())
|
||
{
|
||
_log.LogError("[SimpleLite] Process.Start returned false; exe={Exe} args={Args}", resolved, arguments);
|
||
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||
Warning: $"启动 SimpleLite 进程失败(Process.Start 返回 false);exe={resolved}");
|
||
}
|
||
|
||
_proc = proc;
|
||
_lastLaunchMode = displayMode;
|
||
|
||
// FollowParent 仅在用户显式 opt-in 时启用;UseShellExecute=true 之下 JobObject 仍可绑定 pid,
|
||
// 但会破坏「独立程序」语义,因此默认 false(见 SimpleLiteOptions.FollowParent 说明)。
|
||
if (_opts.FollowParent) AttachToJobObject(proc);
|
||
|
||
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
|
||
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogError(ex, "[SimpleLite] auto-start failed");
|
||
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
|
||
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
var ready = WaitForProjectionReady();
|
||
return new LaunchResult(true, ready ? "Ready" : "StartedButNotReady",
|
||
ready ? $"projection :{_opts.ProjectionPort} reachable, displayMode={displayMode}"
|
||
: $"projection :{_opts.ProjectionPort} did not respond within {_opts.ReadinessTimeoutMs}ms, displayMode={displayMode}",
|
||
DisplayMode: displayMode,
|
||
Warning: ready
|
||
? null
|
||
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 手动关闭 SimpleLite 子进程。
|
||
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
||
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
Process? proc;
|
||
IntPtr job;
|
||
lock (_sync)
|
||
{
|
||
proc = _proc;
|
||
job = _job;
|
||
_proc = null;
|
||
_job = IntPtr.Zero;
|
||
}
|
||
|
||
if (!_opts.FollowParent)
|
||
{
|
||
// 独立模式:不真的杀子进程,只释放本地引用让 GC 收 Process handle。
|
||
// 这样 MiGu.Server 重启时下一次登录可以重新 MaybeStart 拉一个新的 SimpleLite,
|
||
// 而老 SimpleLite 仍然在自己的窗口里跑。
|
||
if (proc is { HasExited: false })
|
||
{
|
||
_log.LogInformation("[SimpleLite] standalone mode: skip kill on Dispose; pid={Pid} keeps running", proc.Id);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (proc is { HasExited: false })
|
||
{
|
||
try { proc.Kill(entireProcessTree: true); }
|
||
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
|
||
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||
}
|
||
|
||
if (job != IntPtr.Zero)
|
||
{
|
||
try { CloseHandle(job); } catch { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
private static string NormalizeDisplayMode(string launchMode)
|
||
{
|
||
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 SimpleLite「内部语言」(web / web+local)。
|
||
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
|
||
return launchMode?.Trim().ToLowerInvariant() switch
|
||
{
|
||
"webonly" or "web" or "web-only" => "web",
|
||
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
||
_ => "web+local",
|
||
};
|
||
}
|
||
|
||
private static string BuildArguments(string displayMode, string extra)
|
||
{
|
||
var args = $"--display-mode={displayMode}";
|
||
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
|
||
return args;
|
||
}
|
||
|
||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||
public SimpleLiteDiagnostics GetDiagnostics()
|
||
{
|
||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||
return new SimpleLiteDiagnostics(
|
||
Enabled: _opts.Enabled,
|
||
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||
ConfiguredWorkingDirectory: _opts.WorkingDirectory ?? "",
|
||
ContentRootPath: _env.ContentRootPath,
|
||
ResolvedExecutablePath: resolved,
|
||
ExecutableExists: resolved != null && File.Exists(resolved),
|
||
IsRunning: IsRunning,
|
||
LastLaunchMode: LastLaunchMode,
|
||
ProjectionPort: _opts.ProjectionPort,
|
||
ProjectionPortReachable: projectionUp,
|
||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json");
|
||
}
|
||
|
||
private string? ResolveExecutable(string configured)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(configured))
|
||
{
|
||
var p = Path.IsPathRooted(configured) ? configured : Path.GetFullPath(configured, _env.ContentRootPath);
|
||
return File.Exists(p) ? p : null;
|
||
}
|
||
|
||
var cwd = _env.ContentRootPath;
|
||
var staticCandidates = new[]
|
||
{
|
||
Path.Combine(cwd, "SimpleLite.exe"),
|
||
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
|
||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||
};
|
||
foreach (var c in staticCandidates)
|
||
{
|
||
if (File.Exists(c)) return Path.GetFullPath(c);
|
||
}
|
||
|
||
// 沿父目录上行兜底(开发机 cwd 可能是 MiGu.Server/bin/Debug/net8.0/)
|
||
var dir = new DirectoryInfo(cwd);
|
||
while (dir != null)
|
||
{
|
||
foreach (var sub in new[]
|
||
{
|
||
"SimpleLite/bin/Debug/SimpleLite.exe",
|
||
"SimpleLite/bin/Release/SimpleLite.exe",
|
||
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
|
||
"Simple/SimpleLite/bin/Release/SimpleLite.exe",
|
||
})
|
||
{
|
||
var p = Path.Combine(dir.FullName, sub.Replace('/', Path.DirectorySeparatorChar));
|
||
if (File.Exists(p)) return Path.GetFullPath(p);
|
||
}
|
||
dir = dir.Parent;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private bool WaitForProjectionReady()
|
||
{
|
||
if (_opts.ReadinessTimeoutMs == 0) return false;
|
||
var deadline = _opts.ReadinessTimeoutMs < 0
|
||
? DateTime.MaxValue
|
||
: DateTime.UtcNow.AddMilliseconds(_opts.ReadinessTimeoutMs);
|
||
var interval = Math.Max(50, _opts.ReadinessPollIntervalMs);
|
||
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
// 进程已死就别等了 —— 端口永远不会就绪。
|
||
lock (_sync)
|
||
{
|
||
if (_proc is null || _proc.HasExited) return false;
|
||
}
|
||
|
||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
|
||
{
|
||
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
|
||
return true;
|
||
}
|
||
|
||
Thread.Sleep(interval);
|
||
}
|
||
|
||
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||
return false;
|
||
}
|
||
|
||
private static bool TryConnect(string host, int port, TimeSpan timeout)
|
||
{
|
||
try
|
||
{
|
||
using var client = new TcpClient();
|
||
var task = client.ConnectAsync(host, port);
|
||
return task.Wait(timeout) && client.Connected;
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
||
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||
/// </summary>
|
||
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
||
{
|
||
try
|
||
{
|
||
using var httpClient = new System.Net.Http.HttpClient
|
||
{
|
||
Timeout = timeout
|
||
};
|
||
using var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, $"http://{host}:{port}/projection/cars");
|
||
using var resp = httpClient.Send(req);
|
||
if (!resp.IsSuccessStatusCode) return false;
|
||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult().TrimStart();
|
||
return body.StartsWith("[", StringComparison.Ordinal) || body.StartsWith("{", StringComparison.Ordinal);
|
||
}
|
||
catch
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拉起 SimpleLite 的结果摘要。
|
||
/// <list type="bullet">
|
||
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
||
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
||
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
||
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
|
||
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
||
string? DisplayMode = null, string? Warning = null);
|
||
|
||
public sealed record SimpleLiteDiagnostics(
|
||
bool Enabled,
|
||
string ConfiguredExecutablePath,
|
||
string ConfiguredWorkingDirectory,
|
||
string ContentRootPath,
|
||
string? ResolvedExecutablePath,
|
||
bool ExecutableExists,
|
||
bool IsRunning,
|
||
string? LastLaunchMode,
|
||
int ProjectionPort,
|
||
bool ProjectionPortReachable,
|
||
string ConfigHint);
|
||
|
||
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────
|
||
|
||
[StructLayout(LayoutKind.Sequential)]
|
||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||
{
|
||
public long PerProcessUserTimeLimit;
|
||
public long PerJobUserTimeLimit;
|
||
public uint LimitFlags;
|
||
public UIntPtr MinimumWorkingSetSize;
|
||
public UIntPtr MaximumWorkingSetSize;
|
||
public uint ActiveProcessLimit;
|
||
public long Affinity;
|
||
public uint PriorityClass;
|
||
public uint SchedulingClass;
|
||
}
|
||
|
||
[StructLayout(LayoutKind.Sequential)]
|
||
private struct IO_COUNTERS
|
||
{
|
||
public ulong ReadOperationCount;
|
||
public ulong WriteOperationCount;
|
||
public ulong OtherOperationCount;
|
||
public ulong ReadTransferCount;
|
||
public ulong WriteTransferCount;
|
||
public ulong OtherTransferCount;
|
||
}
|
||
|
||
[StructLayout(LayoutKind.Sequential)]
|
||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||
{
|
||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||
public IO_COUNTERS IoInfo;
|
||
public UIntPtr ProcessMemoryLimit;
|
||
public UIntPtr JobMemoryLimit;
|
||
public UIntPtr PeakProcessMemoryUsed;
|
||
public UIntPtr PeakJobMemoryUsed;
|
||
}
|
||
|
||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||
private const int JobObjectExtendedLimitInformation = 9;
|
||
|
||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||
|
||
[DllImport("kernel32.dll")]
|
||
[return: MarshalAs(UnmanagedType.Bool)]
|
||
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpInfo, uint cbInfoLength);
|
||
|
||
[DllImport("kernel32.dll")]
|
||
[return: MarshalAs(UnmanagedType.Bool)]
|
||
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||
|
||
[DllImport("kernel32.dll")]
|
||
[return: MarshalAs(UnmanagedType.Bool)]
|
||
private static extern bool CloseHandle(IntPtr hObject);
|
||
|
||
private void AttachToJobObject(Process child)
|
||
{
|
||
if (!OperatingSystem.IsWindows())
|
||
{
|
||
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (_job == IntPtr.Zero)
|
||
{
|
||
_job = CreateJobObject(IntPtr.Zero, null);
|
||
if (_job == IntPtr.Zero)
|
||
{
|
||
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
|
||
return;
|
||
}
|
||
|
||
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||
|
||
int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
||
IntPtr ptr = Marshal.AllocHGlobal(len);
|
||
try
|
||
{
|
||
Marshal.StructureToPtr(info, ptr, false);
|
||
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
||
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
|
||
}
|
||
finally { Marshal.FreeHGlobal(ptr); }
|
||
}
|
||
|
||
if (!AssignProcessToJobObject(_job, child.Handle))
|
||
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||
}
|
||
}
|
||
}
|