using System.Diagnostics;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace MiGu.Server.Launcher;
///
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
///
/// 设计要点(与原 SimpleLite/Platform/PlatformLauncher.cs 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
/// - 默认独立( = false):SimpleLite 与 MiGu.Server 互不影响,
/// 关闭任一方不会 kill 另一方;Windows 上用 cmd /c start 脱离父进程组/控制台。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 --display-mode=web / --display-mode=web+local。
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
///
public sealed class SimpleLiteLauncher : IDisposable
{
private readonly SimpleLiteOptions _opts;
private readonly ILogger _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 opts, ILogger 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; } }
/// 外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。
internal const string ExternalReuseLaunchMode = "external";
///
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
///
/// "WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。
/// 本次调用产生的状态摘要,可写入登录响应或日志。
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;
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
: Path.GetFullPath(_opts.WorkingDirectory);
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}");
}
}
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;可稍后刷新。");
}
///
/// 手动关闭 SimpleLite 子进程。
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
///
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}";
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
var sceneArg = ReadActiveScenesArg();
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
return args;
}
/// 解析 SimpleLite 工作目录(与拉起时一致):优先显式 ,
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。
public string? ResolveWorkingDirectory()
{
if (!string.IsNullOrWhiteSpace(_opts.WorkingDirectory))
return Path.GetFullPath(_opts.WorkingDirectory);
var resolved = ResolveExecutable(_opts.ExecutablePath);
return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory);
}
/// SimpleLite 的 plugins 目录(工作目录/plugins);定位不到工作目录时返回 null。
public string? ResolvePluginsDir()
{
var wd = ResolveWorkingDirectory();
return wd == null ? null : Path.Combine(wd, "plugins");
}
///
/// 把「配置向导选定的导航场景」写入 SimpleLite 的 plugins/active-scenes.json ——
/// 这是「平台 → 内核」选择性加载的主通道。下次 SimpleLite 启动即据此只加载选定导航场景插件;
/// 已在运行的实例需重启或调 POST /projection/scenes/apply 才生效。字段名与 SimpleLite 端
/// ActiveScenesConfig 对齐(activeScenes / alwaysLoad / source / updatedAt)。
///
public ActiveScenesWriteResult WriteActiveScenes(IEnumerable activeScenes, IEnumerable? 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())
.Where(s => !string.IsNullOrWhiteSpace(s)).Distinct().ToList(),
alwaysLoad = (alwaysLoad ?? Enumerable.Empty())
.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);
}
}
/// 从已写入的 active-scenes.json 读取激活场景,拼成 --scenes=a,b(拉起时透传);无内容返回 null。
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; }
}
/// 写 active-scenes.json 的结果(供向导保存接口回显)。
public readonly record struct ActiveScenesWriteResult(bool Ok, string? Path, string? Error);
///
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
///
public LaunchResult RestartForUpdate(string launchMode = "webonly")
{
foreach (var proc in Process.GetProcessesByName("SimpleLite"))
{
try
{
if (!proc.HasExited)
{
proc.Kill(entireProcessTree: true);
_log.LogInformation("[SimpleLite] restart-for-update: killed pid={Pid}", proc.Id);
}
}
catch (Exception ex)
{
_log.LogWarning("[SimpleLite] restart-for-update: kill pid={Pid} failed: {Msg}", proc.Id, ex.Message);
}
finally
{
proc.Dispose();
}
}
Thread.Sleep(1500);
lock (_sync)
{
_proc = null;
_lastLaunchMode = null;
}
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;
}
/// 启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。
public SimpleLiteDiagnostics GetDiagnostics()
{
var resolved = ResolveExecutable(_opts.ExecutablePath);
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
var gotoSite = projectionUp ? SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) : null;
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。");
}
///
/// 拉起 SimpleLite。FollowParent=false 时在 Windows 上用 cmd /c start 脱离父进程组,避免平台退出连带结束 SimpleLite。
///
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;
}
///
/// 通过 cmd start 在新进程组/新控制台中启动,与 MiGu.Server 控制台 Ctrl+C、进程树结束解耦。
///
private Process? StartSimpleLiteDetachedWindows(string resolved, string arguments, string workdir)
{
var exeName = Path.GetFileNameWithoutExtension(resolved);
var beforeIds = new HashSet(
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 = 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;
}
}
///
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
///
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;
}
}
///
/// 拉起 SimpleLite 的结果摘要。
///
/// - Started:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。
/// - Status:状态枚举字符串(见上)。
/// - Detail:技术细节,写入服务端日志。
/// - DisplayMode:本次实际生效的 displayMode(web / web+local / external / null)。
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。
/// - Warning:透传给前端登录响应的告警文本,null 表示无需告警。
///
///
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);
}
}
}