feat(launcher): SimpleLite 进程独立化与 DLL 热更新

- FollowParent=false 时在 Windows 用 cmd /c start 脱离父进程组,关闭 MiGu.Server 不再连带结束 SimpleLite
- 新增 SimpleLiteBuildSync:SimpleLite 未运行时把 obj/Debug 最新 DLL 同步到 bin/Debug,并探测「前往站点」API 是否可用
- 新增 RestartForUpdate 与 POST /api/health/simplelite/restart-for-update:一键关闭、同步、重启以加载新 API
- 诊断增加 FollowParent / GotoSiteApiAvailable / DeployHint;新增 scripts/redeploy-simplelite.ps1 部署脚本
- Dispose 增加 _disposed 幂等保护,避免重复释放
This commit is contained in:
zhaowei.huang
2026-05-29 23:51:23 +08:00
parent a5dd632898
commit 37680a0ae7
4 changed files with 326 additions and 47 deletions
+14 -1
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Launcher;
namespace MiGu.Server.Controllers;
@@ -39,4 +40,16 @@ public class HealthController : ControllerBase
/// </summary>
[HttpGet("simplelite")]
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
/// <summary>
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
/// </summary>
[HttpPost("simplelite/restart-for-update")]
[Authorize]
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
{
var result = _launcher.RestartForUpdate(launchMode);
var diag = _launcher.GetDiagnostics();
return Ok(new { restart = result, diagnostics = diag });
}
}
@@ -0,0 +1,94 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace MiGu.Server.Launcher;
/// <summary>
/// 将 <c>obj/Debug</c> 下最新编译的 SimpleLite 同步到 <c>bin/Debug</c>(仅当 SimpleLite 未运行时)。
/// </summary>
public static class SimpleLiteBuildSync
{
public static bool TrySyncFromObjToBin(string contentRoot, ILogger? log = null)
{
if (!TryResolvePaths(contentRoot, out var objDll, out var objExe, out var binDir))
return false;
var binDll = Path.Combine(binDir, "SimpleLite.dll");
var binExe = Path.Combine(binDir, "SimpleLite.exe");
if (!File.Exists(objDll))
{
log?.LogDebug("[SimpleLiteBuildSync] obj DLL 不存在: {Path}", objDll);
return false;
}
if (Process.GetProcessesByName("SimpleLite").Any(p => !p.HasExited))
{
log?.LogWarning("[SimpleLiteBuildSync] SimpleLite 仍在运行,跳过 DLL 同步。请先关闭 SimpleLite 窗口。");
return false;
}
var objTime = File.GetLastWriteTimeUtc(objDll);
if (File.Exists(binDll) && File.GetLastWriteTimeUtc(binDll) >= objTime)
{
log?.LogDebug("[SimpleLiteBuildSync] bin 已是最新,无需同步");
return false;
}
Directory.CreateDirectory(binDir);
File.Copy(objDll, binDll, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objDll, binDll);
if (File.Exists(objExe))
{
File.Copy(objExe, binExe, true);
log?.LogInformation("[SimpleLiteBuildSync] 已同步 {Src} → {Dst}", objExe, binExe);
}
return true;
}
public static bool? ProbeGotoSiteRoute(int port = 8222)
{
try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
using var resp = client.PostAsync(
$"http://127.0.0.1:{port}/projection/reflection/car/0/goto-site?siteId=0",
null).GetAwaiter().GetResult();
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
return body.Contains("<html", StringComparison.OrdinalIgnoreCase) ? false : true;
return body.Contains("\"success\"", StringComparison.Ordinal);
}
catch
{
return null;
}
}
private static bool TryResolvePaths(string contentRoot, out string objDll, out string objExe, out string binDir)
{
objDll = objExe = "";
binDir = "";
var repo = FindRepoRoot(contentRoot);
if (repo == null) return false;
var sl = Path.Combine(repo, "Simple", "SimpleLite");
objDll = Path.Combine(sl, "obj", "Debug", "SimpleLite.dll");
objExe = Path.Combine(sl, "obj", "Debug", "SimpleLite.exe");
binDir = Path.Combine(sl, "bin", "Debug");
return true;
}
private static string? FindRepoRoot(string contentRoot)
{
var dir = new DirectoryInfo(contentRoot);
while (dir != null)
{
if (Directory.Exists(Path.Combine(dir.FullName, "Simple", "SimpleLite")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}
}
+189 -46
View File
@@ -11,8 +11,9 @@ namespace MiGu.Server.Launcher;
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程;
/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL
/// - 默认独立(<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>
@@ -26,6 +27,7 @@ public sealed class SimpleLiteLauncher : IDisposable
private Process? _proc;
private IntPtr _job = IntPtr.Zero;
private string? _lastLaunchMode;
private bool _disposed;
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
{
@@ -118,13 +120,21 @@ public sealed class SimpleLiteLauncher : IDisposable
// 推断 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: $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。");
Warning: reuseWarning);
}
SimpleLiteBuildSync.TrySyncFromObjToBin(_env.ContentRootPath, _log);
var resolved = ResolveExecutable(_opts.ExecutablePath);
if (resolved == null)
{
@@ -143,52 +153,21 @@ public sealed class SimpleLiteLauncher : IDisposable
var arguments = BuildArguments(displayMode, _opts.Arguments);
// 会话 N+2:把 SimpleLite 作为独立进程拉起,**带自己的控制台窗口**。
// - UseShellExecute=true:交给 OS Shell 启动,生成新的进程组 + 独立 console,
// MiGu.Server 关闭不会影响它(不再处于父进程的「controlling process」链上)。
// - CreateNoWindow=false + 不重定向 stdout/stderrSimpleLite 自带 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 += (_, _) =>
var proc = StartSimpleLiteProcess(resolved, arguments, workdir);
if (proc == null)
{
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);
_log.LogError("[SimpleLite] Process.Start returned null; exe={Exe} args={Args}", resolved, arguments);
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
Warning: $"启动 SimpleLite 进程失败Process.Start 返回 falseexe={resolved}");
Warning: $"启动 SimpleLite 进程失败;exe={resolved}");
}
WireProcessExitHandler(proc);
_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}",
@@ -223,6 +202,8 @@ public sealed class SimpleLiteLauncher : IDisposable
IntPtr job;
lock (_sync)
{
if (_disposed) return;
_disposed = true;
proc = _proc;
job = _job;
_proc = null;
@@ -231,13 +212,12 @@ public sealed class SimpleLiteLauncher : IDisposable
if (!_opts.FollowParent)
{
// 独立模式:不真的杀子进程,释放本地引用让 GC 收 Process handle
// 这样 MiGu.Server 重启时下一次登录可以重新 MaybeStart 拉一个新的 SimpleLite
// 而老 SimpleLite 仍然在自己的窗口里跑。
// 独立模式:不杀子进程,释放 MiGu.Server 侧 Process 句柄
if (proc is { HasExited: false })
{
_log.LogInformation("[SimpleLite] standalone mode: skip kill on Dispose; pid={Pid} keeps running", proc.Id);
_log.LogInformation("[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)", proc.Id);
}
try { proc?.Dispose(); } catch { /* ignore */ }
return;
}
@@ -273,13 +253,64 @@ public sealed class SimpleLiteLauncher : IDisposable
return args;
}
/// <summary>
/// 关闭所有 SimpleLite 进程、同步 obj→bin 最新 DLL,再按 launchMode 重新拉起。
/// </summary>
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;
}
/// <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));
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,
@@ -289,7 +320,116 @@ public sealed class SimpleLiteLauncher : IDisposable
LastLaunchMode: LastLaunchMode,
ProjectionPort: _opts.ProjectionPort,
ProjectionPortReachable: projectionUp,
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json");
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)
@@ -423,6 +563,7 @@ public sealed class SimpleLiteLauncher : IDisposable
public sealed record SimpleLiteDiagnostics(
bool Enabled,
bool FollowParent,
string ConfiguredExecutablePath,
string ConfiguredWorkingDirectory,
string ContentRootPath,
@@ -432,6 +573,8 @@ public sealed class SimpleLiteLauncher : IDisposable
string? LastLaunchMode,
int ProjectionPort,
bool ProjectionPortReachable,
bool? GotoSiteApiAvailable,
string? DeployHint,
string ConfigHint);
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────