diff --git a/MiGu.Server/Controllers/HealthController.cs b/MiGu.Server/Controllers/HealthController.cs index 5494bd8..8ccdd84 100644 --- a/MiGu.Server/Controllers/HealthController.cs +++ b/MiGu.Server/Controllers/HealthController.cs @@ -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 /// [HttpGet("simplelite")] public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics()); + + /// + /// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。 + /// + [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 }); + } } diff --git a/MiGu.Server/Launcher/SimpleLiteBuildSync.cs b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs new file mode 100644 index 0000000..8be821c --- /dev/null +++ b/MiGu.Server/Launcher/SimpleLiteBuildSync.cs @@ -0,0 +1,94 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace MiGu.Server.Launcher; + +/// +/// 将 obj/Debug 下最新编译的 SimpleLite 同步到 bin/Debug(仅当 SimpleLite 未运行时)。 +/// +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("SimpleLite/Platform/PlatformLauncher.cs 镜像): /// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。 /// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。 -/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程; -/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL。 +/// - 默认独立( = 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。 /// @@ -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 opts, ILogger 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; } + /// + /// 关闭所有 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, @@ -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。"); + } + + /// + /// 拉起 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) @@ -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 ──────────────────────── diff --git a/scripts/redeploy-simplelite.ps1 b/scripts/redeploy-simplelite.ps1 new file mode 100644 index 0000000..096b545 --- /dev/null +++ b/scripts/redeploy-simplelite.ps1 @@ -0,0 +1,29 @@ +# 将最新编译的 SimpleLite 部署到 bin\Debug 并提示重启。 +# 用法:先关闭 SimpleLite(任务管理器或托盘退出),再运行本脚本。 + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$slProj = Join-Path $repoRoot 'Simple\SimpleLite\SimpleLite.csproj' +$outDir = Join-Path $repoRoot 'Simple\SimpleLite\bin\Debug' +$objDll = Join-Path $repoRoot 'Simple\SimpleLite\obj\Debug\SimpleLite.dll' + +Write-Host '==> 编译 SimpleLite (DisableFody)...' -ForegroundColor Cyan +dotnet build $slProj -c Debug -p:DisableFody=true +if ($LASTEXITCODE -ne 0) { throw "dotnet build 失败 (exit $LASTEXITCODE)" } + +$builtDll = if (Test-Path $objDll) { $objDll } else { Join-Path $outDir 'SimpleLite.dll' } + +if (Get-Process SimpleLite -ErrorAction SilentlyContinue) { + Write-Host '' + Write-Host 'SimpleLite 仍在运行,无法覆盖 DLL。请先关闭 SimpleLite.exe,再重新运行本脚本。' -ForegroundColor Yellow + exit 1 +} + +Copy-Item $builtDll (Join-Path $outDir 'SimpleLite.dll') -Force +$objExe = Join-Path $repoRoot 'Simple\SimpleLite\obj\Debug\SimpleLite.exe' +if (Test-Path $objExe) { + Copy-Item $objExe (Join-Path $outDir 'SimpleLite.exe') -Force +} + +Write-Host '==> 已更新' $outDir -ForegroundColor Green +Write-Host '请重新登录迷榖平台(或手动启动 SimpleLite.exe)以加载新 API。'