Skip to content

防双开与运行态 ​

「这台机器上现在跑着哪些游戏」这个问题,答案不能只存在启动器的内存里——启动器会崩、会重启、会被用户关掉。所以我们把它落盘。

注册表:一个实例一个文件 ​

<dotMc>/starcloud/game-session-<pid>.json

内容只有四项:

csharp
public sealed class GameSessionLock
{
    [JsonPropertyName("pid")] public int Pid { get; set; }
    [JsonPropertyName("version_id")] public string VersionId { get; set; } = "";
    [JsonPropertyName("started_at")] public DateTime StartedAt { get; set; }

    /// <summary>本局日志文件(供重启后仍能读日志尾;老文件缺失时为 null)</summary>
    [JsonPropertyName("log_file")] public string? LogFile { get; set; }
}

一实例一文件、pid 即实例键,而不是一个大 JSON 数组。理由是并发:多个实例同时注册/注销时,改一个文件不会和改另一个文件抢写锁;删一个实例就是删一个文件。

写文件是原子的(tmp + File.Move overwrite)。

为什么落盘

内存里的 GameRuntimeMonitor 只知道自己这一轮拉起的进程。启动器重启之后——用户重启了电脑、启动器崩了、用户手动关了启动器——进程可能还在跑(Java 是独立进程,不随启动器死)。这时候只有落盘的注册文件能让新的启动器知道"哦,这台机器上还有一局在跑"。

这就是注册表存在的全部理由:跨进程唯一真相。

写侧只有 Publish / Unpublish 一对,而且由 GameRuntimeMonitor 独占调用:

csharp
// 写侧只有 Publish / Unpublish 一对,且由 GameRuntimeMonitor 独占调用:
// 注册文件的生命周期 = 实例在监控器里的生命周期,页面/启动路径不再持有句柄(多开时不能互相删文件)。

多开时这个约束是必须的:A 局退出时不能顺手删掉 B 局的注册文件。

判活:pid + 进程名 ​

csharp
/// <summary>pid 存活且进程名形如 java* 才算真在跑(防 pid 复用误判)</summary>
private static bool IsAliveJavaProcess(int pid)
{
    if (pid <= 0) return false;
    try
    {
        using var p = Process.GetProcessById(pid);
        return !p.HasExited && p.ProcessName.StartsWith("java", StringComparison.OrdinalIgnoreCase);
    }
    catch { return false; }
}

只看 pid 是不够的。pid 会被系统回收复用——上一局的 java 挂了之后,新起的某个进程拿到同一个 pid,光看 pid 存活就会误判成"游戏还在跑"。加上进程名必须以 java 开头(不区分大小写,Windows 上是 java / javaw),误判概率就压到很低。GameRuntimeMonitor.DefaultAliveProbe 用的是同一套口径。

所有查询都是「按 pid 判活过滤」后的结果,且无副作用;清理死文件是另一件事,只在 Publish 时顺手做(PruneDead),避免状态目录无限增长。

运行态监控器 ​

GameRuntimeMonitor 是 Core 里的单例(GameRuntimeMonitor.Instance),因为 Nova 的启动路径在 Core 层够不到 AppServices。它不知道 UI 的存在——只抛不可变快照和事件,由页面自己回 UI 线程。

csharp
public sealed record GameRunInfo(
    int Pid, string VersionId, DateTime StartedAt, TimeSpan Uptime,
    bool RecoveredFromLock, GameRunSample Latest);

RecoveredFromLock 标记这一局是不是"恢复来的"(启动器重启后从注册文件认出来的)。

1 秒采样 ​

心跳是一个 System.Threading.Timer,默认间隔 1000ms,间隔可调(钳在 250–10000ms,改了会重建定时器)。每次采样读 CPU、工作集、峰值工作集、线程数、GPU 占用。

CPU 用差分口径:

csharp
var elapsed = (nowAt - _lastSampleAt).TotalSeconds;
if (elapsed > 0.5)
{
    cpu = (nowCpu - _lastCpuTime).TotalSeconds / elapsed / Environment.ProcessorCount * 100.0;
}

间隔过短(≤0.5s)时不给差分,避免除零和假尖峰。

GPU 走 Windows PDH 计数器(\GPU Engine(pid_<pid>_*)\Utilization Percentage),任何失败返回 null 而不是 0——null 表示"计数器不可用",0 表示"真的没占用",两者不能混。连续失败两次就判死(_gpuBroken),本局不再尝试。

环形缓冲 600 点 ​

csharp
public const int SampleCapacity = 600;   // ≈10 分钟 @1s
st.Samples.AddLast(sample);
while (st.Samples.Count > SampleCapacity) st.Samples.RemoveFirst();

600 个点够画十分钟的曲线,同时把内存占用钉死。峰值工作集单独用字段记,不依赖缓冲里的历史。

连续 3 次失败 → 采样已暂停 ​

csharp
private const int MaxConsecutiveFailures = 3;
st.ConsecutiveFailures++;
if (st.ConsecutiveFailures >= MaxConsecutiveFailures && !st.SamplingPaused)
{
    st.SamplingPaused = true;
}

采样失败通常是进程已经退出(ProcessSampler.TrySample 在 HasExited 时返回 false)。连续 3 次失败就认为"这个进程可能已经没了或者我们拿不到它的数据",页面显示「采样已暂停」并给一个「重试」按钮——重试就是清掉暂停态并重置心跳。

为什么不 1 次失败就暂停?因为单次失败可能是瞬时的(进程正好在忙、句柄短暂失效)。3 次连续失败(≈3 秒)才判,是拿"响应速度"换"误判率"。

核心坑:恢复路径没有 Exited 事件 ​

这是整个运行态里最容易出 bug 的一处。

接管路径(这一局是本进程启动的):有 GameSession,进程退出时 GameSession.CompleteAsync 抛 Exited 事件,监控器订阅它来收口:

csharp
Action<int> handler = code => OnSessionExited(pid, code);
session.Exited += handler;

恢复路径(上一轮启动器拉起的,本进程没有 GameSession 对象):没有 Exited 事件,因为那个 GameSession 从来不存在于本进程,没人抛事件。

那进程死了怎么知道?只能靠心跳采样失败。但采样失败的语义是"读不到数据"而不是"进程死了"——可能是采样器坏了,可能是权限问题。所以常规逻辑会等到连续 3 次失败才置"采样已暂停"。

必须第 1 次失败就收口

如果按常规等 3 次失败,这局的状态会永远停在「运行中 + 采样已暂停」——进程早就死了,页面却显示还在运行,历史记录里也永远没有这一局。

所以恢复路径的收口必须发生在第 1 次采样失败时:

csharp
// 恢复路径没有 GameSession.Exited 事件,进程死掉只能靠心跳收口。
// 收口发生在第 1 次失败(早于 3 次失败阈值),所以必须在这里就落历史并移除状态,
// 否则页面会永远停在「运行中 + 采样已暂停」。
if (st.Recovered && FinishLocked(st, null) is { } rec)
{
    finished.Add(rec);
    changed = true;
}

条件是 st.Recovered——只有恢复路径走这条。接管路径不能这么干,否则进程退出时的心跳采样会先于 Exited 事件触发,导致落两条历史。

另一坑:命令行只在内存里 ​

启动命令行含 --accessToken。它是明文的:

csharp
vars["auth_access_token"] = authToken;

所以它绝不落盘。GameSession 只把它存在内存属性上:

csharp
/// <summary>本局完整启动命令行(含 --accessToken,仅内存持有,绝不落盘)</summary>
public string? CommandLine { get; }

注册文件 game-session-<pid>.json 里没有它,启动上下文 game-launch-<pid>.json 里也没有——GameLaunchContextStore 的注释写得很直白:

csharp
// 刻意没有完整命令行的字段:BuildCommandLineAsync 的产物含 --accessToken,绝不落盘。

上下文只存能展示的东西:Java 路径、内存、窗口尺寸、隔离模式、模组/光影/资源包计数。

恢复路径拿不到命令行

进程重启后,命令行只存在于原来那个启动器进程的内存里。新启动器恢复这一局时没有 GameSession,也就没有 CommandLine。

所以运行状态页的"复制命令"按钮在恢复路径下必须置灰:

csharp
// 命令行含 --accessToken,只在本进程内存里;恢复路径必然拿不到
var cmd = _s.Runtime.CommandLine;
var hasCmd = !string.IsNullOrEmpty(cmd);
CopyCmdBtn.IsEnabled = hasCmd;

这不是功能缺失,是有意的信息边界:宁可不给,也不把令牌写到别的地方去。

恢复路径的日志尾 ​

恢复路径没有内存环形缓冲,TailLog 改成读本局日志文件的末尾,只回读文件末尾 64KB(TailReadBytes),不整文件加载——日志可能 20MB。回读起点可能落在半行上,所以首行丢弃(除非整文件已读完):

csharp
if (take < fs.Length && lines.Count > 0) lines.RemoveAt(0);

运行历史 ​

收口时落一条 GameRunRecord:版本、起止时间、时长、退出码、峰值内存、是否崩溃。

csharp
Crashed: !killedByUser && exitCode is not (null or 0)

KilledByUser 在两条路径上都可能被置位——用户在界面里点"结束游戏",或者 GameSession.Kill() 被调过。区分"用户主动结束"和"游戏崩溃"靠的就是它:用户主动结束的不算崩溃,哪怕退出码非零。

恢复路径收口时 exitCode 传 null——我们从来没观察到那个进程的退出码(它不是我们启动的),所以 Crashed 只能是 false。宁可漏报一次崩溃,也不要误报。

相关链接:进程与日志 · 从点击到游戏起来 · 启动参数怎么构建

基于 MIT 许可证(有修改)开源