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:
@@ -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/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 += (_, _) =>
|
||||
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 返回 false);exe={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 ────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user