2026-05-29 18:16:34 +08:00
using System.Diagnostics ;
using System.Net.Sockets ;
using System.Runtime.InteropServices ;
2026-06-03 09:37:07 +08:00
using System.Text.Json ;
2026-05-29 18:16:34 +08:00
using Microsoft.Extensions.Options ;
namespace MiGu.Server.Launcher ;
/// <summary>
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
///
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
2026-05-29 23:51:23 +08:00
/// - 默认独立(<see cref="SimpleLiteOptions.FollowParent"/> = false):SimpleLite 与 MiGu.Server 互不影响,
/// 关闭任一方不会 kill 另一方;Windows 上用 <c>cmd /c start</c> 脱离父进程组/控制台。
/// - 可选跟随(FollowParent = true):Windows JobObject 绑定,MiGu.Server 退出时一并结束 SimpleLite。
2026-05-29 18:16:34 +08:00
/// - 命令行透传:把 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 ;
2026-05-29 23:51:23 +08:00
private bool _disposed ;
2026-05-29 18:16:34 +08:00
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>
2026-06-08 16:10:34 +08:00
public LaunchResult MaybeStart ( string launchMode , bool waitForReady = true )
2026-05-29 18:16:34 +08:00
{
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 ;
2026-05-29 23:51:23 +08:00
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。" ;
}
2026-05-29 18:16:34 +08:00
return new LaunchResult ( true , "ReusingExisting" ,
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance" ,
DisplayMode : ExternalReuseLaunchMode ,
2026-05-29 23:51:23 +08:00
Warning : reuseWarning );
2026-05-29 18:16:34 +08:00
}
2026-05-29 23:51:23 +08:00
SimpleLiteBuildSync . TrySyncFromObjToBin ( _env . ContentRootPath , _log );
2026-05-29 18:16:34 +08:00
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
2026-06-08 16:10:34 +08:00
: ResolveConfiguredDirectory ( _opts . WorkingDirectory ) ?? Path . GetDirectoryName ( resolved ) ?? Environment . CurrentDirectory ;
2026-05-29 18:16:34 +08:00
var arguments = BuildArguments ( displayMode , _opts . Arguments );
try
{
2026-05-29 23:51:23 +08:00
var proc = StartSimpleLiteProcess ( resolved , arguments , workdir );
if ( proc == null )
2026-05-29 18:16:34 +08:00
{
2026-05-29 23:51:23 +08:00
_log . LogError ( "[SimpleLite] Process.Start returned null; exe={Exe} args={Args}" , resolved , arguments );
2026-05-29 18:16:34 +08:00
return new LaunchResult ( false , "ProcessStartFailed" , $"exe={resolved}" , DisplayMode : null ,
2026-05-29 23:51:23 +08:00
Warning : $"启动 SimpleLite 进程失败;exe={resolved}" );
2026-05-29 18:16:34 +08:00
}
2026-05-29 23:51:23 +08:00
WireProcessExitHandler ( proc );
2026-05-29 18:16:34 +08:00
_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}" );
}
}
2026-06-08 16:10:34 +08:00
// 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 );
}
2026-05-29 18:16:34 +08:00
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 )
{
2026-05-29 23:51:23 +08:00
if ( _disposed ) return ;
_disposed = true ;
2026-05-29 18:16:34 +08:00
proc = _proc ;
job = _job ;
_proc = null ;
_job = IntPtr . Zero ;
}
if (! _opts . FollowParent )
{
2026-05-29 23:51:23 +08:00
// 独立模式:不杀子进程,仅释放 MiGu.Server 侧 Process 句柄。
2026-05-29 18:16:34 +08:00
if ( proc is { HasExited : false })
{
2026-05-29 23:51:23 +08:00
_log . LogInformation ( "[SimpleLite] standalone mode: MiGu.Server stopping — SimpleLite pid={Pid} keeps running (FollowParent=false)" , proc . Id );
2026-05-29 18:16:34 +08:00
}
2026-05-29 23:51:23 +08:00
try { proc ?. Dispose (); } catch { /* ignore */ }
2026-05-29 18:16:34 +08:00
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" ,
};
}
2026-06-03 09:37:07 +08:00
private string BuildArguments ( string displayMode , string extra )
2026-05-29 18:16:34 +08:00
{
var args = $"--display-mode={displayMode}" ;
2026-06-23 13:47:28 +08:00
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
if ( _opts . EmbeddedCanvas ) args += " --migu" ;
2026-06-03 09:37:07 +08:00
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
var sceneArg = ReadActiveScenesArg ();
if (! string . IsNullOrEmpty ( sceneArg )) args += " " + sceneArg ;
2026-05-29 18:16:34 +08:00
if (! string . IsNullOrWhiteSpace ( extra )) args += " " + extra . Trim ();
return args ;
}
2026-06-03 09:37:07 +08:00
/// <summary>解析 SimpleLite 工作目录(与拉起时一致):优先显式 <see cref="SimpleLiteOptions.WorkingDirectory"/>,
/// 否则取解析到的 exe 所在目录。两者都拿不到返回 null。</summary>
public string? ResolveWorkingDirectory ()
{
if (! string . IsNullOrWhiteSpace ( _opts . WorkingDirectory ))
2026-06-08 16:10:34 +08:00
return ResolveConfiguredDirectory ( _opts . WorkingDirectory );
2026-06-03 09:37:07 +08:00
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 );
2026-05-29 23:51:23 +08:00
/// <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 ;
}
2026-06-12 23:00:47 +08:00
private bool? _gotoSiteProbeCache ;
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset . MinValue ;
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan . FromSeconds ( 60 );
2026-05-29 18:16:34 +08:00
/// <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 ));
2026-06-12 23:00:47 +08:00
// 探测结果缓存 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 ;
}
}
}
2026-05-29 23:51:23 +08:00
var deployHint = gotoSite == false
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
: null ;
2026-05-29 18:16:34 +08:00
return new SimpleLiteDiagnostics (
Enabled : _opts . Enabled ,
2026-05-29 23:51:23 +08:00
FollowParent : _opts . FollowParent ,
2026-05-29 18:16:34 +08:00
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 ,
2026-05-29 23:51:23 +08:00
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 ;
}
}
};
2026-05-29 18:16:34 +08:00
}
private string? ResolveExecutable ( string configured )
{
if (! string . IsNullOrWhiteSpace ( configured ))
{
2026-06-08 16:10:34 +08:00
var p = ResolveConfiguredFile ( configured );
if ( p != null ) return p ;
2026-05-29 18:16:34 +08:00
}
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 ;
}
2026-06-08 16:10:34 +08:00
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 ;
}
}
2026-05-29 18:16:34 +08:00
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 ,
2026-05-29 23:51:23 +08:00
bool FollowParent ,
2026-05-29 18:16:34 +08:00
string ConfiguredExecutablePath ,
string ConfiguredWorkingDirectory ,
string ContentRootPath ,
string? ResolvedExecutablePath ,
bool ExecutableExists ,
bool IsRunning ,
string? LastLaunchMode ,
int ProjectionPort ,
bool ProjectionPortReachable ,
2026-05-29 23:51:23 +08:00
bool? GotoSiteApiAvailable ,
string? DeployHint ,
2026-05-29 18:16:34 +08:00
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 );
}
}
}