拆分 StopAll/Restart,运维代理透传确认头与用户信息,服务状态页可更稳地重启进程。 Co-authored-by: Cursor <cursoragent@cursor.com>
848 lines
37 KiB
C#
848 lines
37 KiB
C#
using System.Diagnostics;
|
||
using System.Net.Sockets;
|
||
using System.Runtime.InteropServices;
|
||
using System.Text.Json;
|
||
using Microsoft.Extensions.Options;
|
||
|
||
namespace MiGu.Server.Launcher;
|
||
|
||
/// <summary>
|
||
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||
///
|
||
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
|
||
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
|
||
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
|
||
/// - 命令行透传:把 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;
|
||
private bool _disposed;
|
||
|
||
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, bool waitForReady = true)
|
||
{
|
||
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;
|
||
|
||
var reuseWarning = $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。";
|
||
if (SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) == false)
|
||
{
|
||
reuseWarning += " 当前 SimpleLite 版本过旧,缺少「前往站点」API;请关闭 SimpleLite 窗口后调用 POST /api/health/simplelite/restart-for-update,或运行 scripts/redeploy-simplelite.ps1。";
|
||
}
|
||
|
||
return new LaunchResult(true, "ReusingExisting",
|
||
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
|
||
DisplayMode: ExternalReuseLaunchMode,
|
||
Warning: reuseWarning);
|
||
}
|
||
|
||
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||
|
||
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
|
||
: ResolveConfiguredDirectory(_opts.WorkingDirectory) ?? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory;
|
||
|
||
var arguments = BuildArguments(displayMode, _opts.Arguments);
|
||
|
||
try
|
||
{
|
||
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
|
||
if (proc == null)
|
||
{
|
||
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
|
||
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
|
||
}
|
||
|
||
WireProcessExitHandler(proc);
|
||
|
||
_proc = proc;
|
||
_lastLaunchMode = displayMode;
|
||
|
||
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}");
|
||
}
|
||
}
|
||
|
||
// M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长
|
||
// ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询
|
||
// GET /api/health/simplelite 获知就绪状态。
|
||
if (!waitForReady)
|
||
{
|
||
return new LaunchResult(true, "Starting",
|
||
$"projection :{_opts.ProjectionPort} readiness wait skipped (async), displayMode={displayMode}",
|
||
DisplayMode: displayMode,
|
||
Warning: null);
|
||
}
|
||
|
||
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)
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
proc = _proc;
|
||
job = _job;
|
||
_proc = null;
|
||
_job = IntPtr.Zero;
|
||
}
|
||
|
||
if (!_opts.FollowParent)
|
||
{
|
||
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
|
||
if (proc is { HasExited: false })
|
||
{
|
||
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
|
||
}
|
||
try { proc?.Dispose(); } catch { /* ignore */ }
|
||
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 string BuildArguments(string displayMode, string extra)
|
||
{
|
||
var args = $"--display-mode={displayMode}";
|
||
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
|
||
if (_opts.EmbeddedCanvas) args += " --migu";
|
||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||
var sceneArg = ReadActiveScenesArg();
|
||
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
|
||
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
|
||
return args;
|
||
}
|
||
|
||
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>,
|
||
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
|
||
public string? ResolveWorkingDirectory()
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(_opts.WorkingDirectory))
|
||
return ResolveConfiguredDirectory(_opts.WorkingDirectory);
|
||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
|
||
}
|
||
|
||
/// <summary>SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。</summary>
|
||
public string? ResolvePluginsDir()
|
||
{
|
||
var wd = ResolveWorkingDirectory();
|
||
return wd == null ? null : Path.Combine(wd, "plugins");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 <c>plugins/active-scenes.json</c> ——
|
||
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
|
||
/// 已在运行的实例需重启或调 <c>POST /projection/scenes/apply</c> 才生效。字段名与 SimpleLite 端
|
||
/// <c>ActiveScenesConfig</c> 对齐(activeScenes / alwaysLoad / source / updatedAt)。
|
||
/// </summary>
|
||
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable<string> activeScenes, IEnumerable<string>? alwaysLoad, string source)
|
||
{
|
||
var pluginsDir = ResolvePluginsDir();
|
||
if (pluginsDir == null)
|
||
return new ActiveScenesWriteResult(false, null, "未找到 SimpleLite 工作目录/可执行文件,无法定位 plugins 目录");
|
||
try
|
||
{
|
||
Directory.CreateDirectory(pluginsDir);
|
||
var path = Path.Combine(pluginsDir, "active-scenes.json");
|
||
var payload = new
|
||
{
|
||
activeScenes = (activeScenes ?? Enumerable.Empty<string>())
|
||
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(),
|
||
alwaysLoad = (alwaysLoad ?? Enumerable.Empty<string>())
|
||
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(),
|
||
source = string.IsNullOrWhiteSpace(source) ? "deployment-profile" : source,
|
||
updatedAt = DateTime.UtcNow
|
||
};
|
||
File.WriteAllText(path, JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }));
|
||
_log.LogInformation("[SimpleLite] active-scenes.json 写入 {Path}: active=[{Scenes}]",
|
||
path, string.Join(",", payload.activeScenes));
|
||
return new ActiveScenesWriteResult(true, path, null);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning(ex, "[SimpleLite] 写 active-scenes.json 失败");
|
||
return new ActiveScenesWriteResult(false, null, ex.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>从已写入的 active-scenes.json 读取激活场景,拼成 <c>--scenes=a,b</c>(拉起时透传);无内容返回 null。</summary>
|
||
private string? ReadActiveScenesArg()
|
||
{
|
||
try
|
||
{
|
||
var pluginsDir = ResolvePluginsDir();
|
||
if (pluginsDir == null) return null;
|
||
var path = Path.Combine(pluginsDir, "active-scenes.json");
|
||
if (!File.Exists(path)) return null;
|
||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||
if (!doc.RootElement.TryGetProperty("activeScenes", out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||
return null;
|
||
var ids = arr.EnumerateArray()
|
||
.Where(e => e.ValueKind == JsonValueKind.String)
|
||
.Select(e => e.GetString())
|
||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||
.ToList();
|
||
return ids.Count == 0 ? null : "--scenes=" + string.Join(",", ids);
|
||
}
|
||
catch { return null; }
|
||
}
|
||
|
||
/// <summary>写 active-scenes.json 的结果(供向导保存接口回显)。</summary>
|
||
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
|
||
|
||
/// <summary>终止本机全部 SimpleLite 进程并清理 MiGu.Server 侧托管引用。</summary>
|
||
public int StopAll()
|
||
{
|
||
var killed = 0;
|
||
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
|
||
{
|
||
try
|
||
{
|
||
if (!proc.HasExited)
|
||
{
|
||
proc.Kill(entireProcessTree: true);
|
||
killed++;
|
||
_log.LogInformation("[SimpleLite] stop: killed pid={Pid}", proc.Id);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning("[SimpleLite] stop: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
|
||
}
|
||
finally
|
||
{
|
||
proc.Dispose();
|
||
}
|
||
}
|
||
|
||
if (killed > 0) Thread.Sleep(1500);
|
||
|
||
lock (_sync)
|
||
{
|
||
_proc = null;
|
||
_lastLaunchMode = null;
|
||
}
|
||
|
||
return killed;
|
||
}
|
||
|
||
/// <summary>关闭全部 SimpleLite 后按 launchMode 重新拉起(不同步 DLL)。</summary>
|
||
public LaunchResult Restart(string launchMode = "webonly")
|
||
{
|
||
StopAll();
|
||
return MaybeStart(launchMode);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
|
||
/// </summary>
|
||
public LaunchResult RestartForUpdate(string launchMode = "webonly")
|
||
{
|
||
StopAll();
|
||
|
||
var synced = SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
|
||
var result = MaybeStart(launchMode);
|
||
if (!synced && result.Warning == null)
|
||
{
|
||
return result with
|
||
{
|
||
Warning = "未能从 obj/Debug 同步 DLL(可能未编译或 SimpleLite 仍占用文件)。请先运行 scripts/redeploy-simplelite.ps1。"
|
||
};
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private bool? _gotoSiteProbeCache;
|
||
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
|
||
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
||
|
||
/// <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));
|
||
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
|
||
bool? gotoSite = null;
|
||
if (projectionUp)
|
||
{
|
||
if (_gotoSiteProbeCache is bool cached && DateTimeOffset.UtcNow - _gotoSiteProbeAt < GotoSiteProbeTtl)
|
||
{
|
||
gotoSite = cached;
|
||
}
|
||
else
|
||
{
|
||
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||
if (gotoSite is not null)
|
||
{
|
||
_gotoSiteProbeCache = gotoSite;
|
||
_gotoSiteProbeAt = DateTimeOffset.UtcNow;
|
||
}
|
||
}
|
||
}
|
||
var deployHint = gotoSite == false
|
||
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
||
: null;
|
||
return new SimpleLiteDiagnostics(
|
||
Enabled: _opts.Enabled,
|
||
FollowParent: _opts.FollowParent,
|
||
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,
|
||
GotoSiteApiAvailable: gotoSite,
|
||
DeployHint: deployHint,
|
||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json。" +
|
||
" FollowParent=false 时关闭 MiGu.Server 不会结束 SimpleLite。");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 <c>cmd /c start</c> 脱离父进程组,避免平台退出连带结束 SimpleLite。
|
||
/// </summary>
|
||
private Process? StartSimpleLiteProcess(string resolved, string arguments, string workdir)
|
||
{
|
||
if (_opts.FollowParent)
|
||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||
|
||
if (OperatingSystem.IsWindows())
|
||
return StartSimpleLiteDetachedWindows(resolved, arguments, workdir)
|
||
?? StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||
|
||
return StartSimpleLiteDirect(resolved, arguments, workdir, useShellExecute: true);
|
||
}
|
||
|
||
private static Process? StartSimpleLiteDirect(string resolved, string arguments, string workdir, bool useShellExecute)
|
||
{
|
||
var psi = new ProcessStartInfo
|
||
{
|
||
FileName = resolved,
|
||
Arguments = arguments,
|
||
WorkingDirectory = workdir,
|
||
UseShellExecute = useShellExecute,
|
||
CreateNoWindow = false,
|
||
WindowStyle = ProcessWindowStyle.Normal,
|
||
};
|
||
var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||
return proc.Start() ? proc : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
|
||
/// </summary>
|
||
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
|
||
{
|
||
var exeName = Path.GetFileNameWithoutExtension(resolved);
|
||
var beforeIds = new HashSet<int>(
|
||
Process.GetProcessesByName(exeName).Select(p => { try { return p.Id; } catch { return -1; } })
|
||
.Where(id => id > 0));
|
||
|
||
var cmdArgs = $"/c start \"SimpleLite\" /D \"{workdir}\" \"{resolved}\" {arguments}";
|
||
var shimPsi = new ProcessStartInfo
|
||
{
|
||
FileName = "cmd.exe",
|
||
Arguments = cmdArgs,
|
||
WorkingDirectory = workdir,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true,
|
||
};
|
||
|
||
using var shim = Process.Start(shimPsi);
|
||
shim?.WaitForExit(8000);
|
||
|
||
for (var i = 0; i < 25; i++)
|
||
{
|
||
foreach (var p in Process.GetProcessesByName(exeName))
|
||
{
|
||
try
|
||
{
|
||
if (p.HasExited) continue;
|
||
if (beforeIds.Contains(p.Id)) continue;
|
||
_log.LogInformation("[SimpleLite] detached start via cmd.exe; new pid={Pid}", p.Id);
|
||
return AttachToExistingProcess(p.Id);
|
||
}
|
||
catch { /* ignore */ }
|
||
}
|
||
Thread.Sleep(200);
|
||
}
|
||
|
||
_log.LogWarning("[SimpleLite] detached start: no new {Name} process observed after cmd.exe start", exeName);
|
||
return null;
|
||
}
|
||
|
||
private Process? AttachToExistingProcess(int pid)
|
||
{
|
||
try
|
||
{
|
||
var proc = Process.GetProcessById(pid);
|
||
proc.EnableRaisingEvents = true;
|
||
return proc;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning("[SimpleLite] attach to pid={Pid} failed: {Msg}", pid, ex.Message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private void WireProcessExitHandler(Process proc)
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
private string? ResolveExecutable(string configured)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(configured))
|
||
{
|
||
var p = ResolveConfiguredFile(configured);
|
||
if (p != null) return p;
|
||
}
|
||
|
||
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 string? ResolveConfiguredFile(string configured)
|
||
{
|
||
foreach (var baseDir in EnumeratePathBases())
|
||
{
|
||
var p = Path.IsPathRooted(configured)
|
||
? configured
|
||
: Path.GetFullPath(configured, baseDir);
|
||
if (File.Exists(p)) return p;
|
||
if (Path.IsPathRooted(configured)) break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private string? ResolveConfiguredDirectory(string configured)
|
||
{
|
||
foreach (var baseDir in EnumeratePathBases())
|
||
{
|
||
var p = Path.IsPathRooted(configured)
|
||
? configured
|
||
: Path.GetFullPath(configured, baseDir);
|
||
if (Directory.Exists(p)) return p;
|
||
if (Path.IsPathRooted(configured)) break;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private IEnumerable<string> EnumeratePathBases()
|
||
{
|
||
var dir = new DirectoryInfo(_env.ContentRootPath);
|
||
while (dir != null)
|
||
{
|
||
yield return dir.FullName;
|
||
dir = dir.Parent;
|
||
}
|
||
}
|
||
|
||
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,
|
||
bool FollowParent,
|
||
string ConfiguredExecutablePath,
|
||
string ConfiguredWorkingDirectory,
|
||
string ContentRootPath,
|
||
string? ResolvedExecutablePath,
|
||
bool ExecutableExists,
|
||
bool IsRunning,
|
||
string? LastLaunchMode,
|
||
int ProjectionPort,
|
||
bool ProjectionPortReachable,
|
||
bool? GotoSiteApiAvailable,
|
||
string? DeployHint,
|
||
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);
|
||
}
|
||
}
|
||
}
|