Skip to content

进程与日志 ​

游戏是一个独立进程。启动器要做两件事:把它拉起来,并且持续地把它的输出收下来。第二件比第一件难。

命令行拼接:不能按空格切 ​

GameLauncher.BuildCommandLineAsync 先把 java 路径和参数各自 QuoteIfNeeded(含空格就加双引号),再用空格 join 成一个字符串。到了 Launch 这一边必须原样还原:

csharp
// 不能按空格切命令行:Java 路径几乎总在 "Program Files" 这类含空格目录下。
// 先做引号感知切分,再交给 ArgumentList 逐项转义。
var parts = SplitCommandLine(commandLine);

SplitCommandLine 自己实现了引号感知的切分:遇到 " 切换 inQuotes,只在引号外的空白处断词。它只处理"整段参数被双引号包住"这一种情形——启动参数里不存在嵌套引号,加个完整的 Windows 命令行解析器反而是负担。

按空格切 = 找不到 java.exe

C:\Program Files\Java\jdk-21\bin\java.exe 按空格切会变成 C:\Program 和 Files\Java\... 两段,第一段不是可执行文件,报的是"找不到文件"而不是"路径写错了",很难查。

拿到切分结果后不用 psi.Arguments 字符串,而是用 ArgumentList 逐项加:

csharp
for (var i = 1; i < parts.Count; i++) psi.ArgumentList.Add(parts[i]);

ArgumentList 会自己按 Windows 的规则给每一项加引号和转义。这样即使某个参数里含有引号、空格、&、|(比如某些 mod 的 --server 参数),也不会被 shell 解释。

日志重定向:必须持续读流 ​

GameSession 构造时同时起两个 pump:

csharp
_stdoutPump = PumpAsync(process.StandardOutput, "OUT");
_stderrPump = PumpAsync(process.StandardError, "ERR");
_lifecycle = CompleteAsync();

每读到一行就做三件事:进内存环形缓冲、写文件、抛 OutputLine 事件给 UI。

不读流 = 游戏卡死

重定向了 stdout 却不读,管道缓冲(Windows 上默认 4KB)写满后游戏进程会阻塞在 write 上——表现是游戏启动到一半就停住,没有任何报错。所以 pump 必须是后台任务且永不停止读。

PumpAsync 是 while(true) { await reader.ReadLineAsync(); },读到 null 才结束,异常(进程被杀时读流会抛)也被吞掉——因为那时候进程已经没了。

内存环形缓冲 1000 行 ​

csharp
private const int TailLimit = 1000;
_recent.Enqueue(text);
while (_recent.Count > TailLimit) _recent.Dequeue();

这是给 UI 的实时日志面板用的:全量日志可能几万行,但面板只需要最近一段。Tail(count) 在锁内取快照返回。

单文件 20MB 上限 ​

csharp
private const long MaxLogBytes = 20 * 1024 * 1024;
writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {text}");
if (writer.BaseStream.Length > MaxLogBytes)
{
    _logFull = true;
    writer.WriteLine("[log] 本局日志超过 20 MB,已停止写入");
}

超限后 _logFull 置位,不再写文件,但读流继续——内存缓冲和 UI 事件照旧,只是落盘停了。写日志失败也不影响游戏。

日志行带毫秒时间戳和 [OUT] / [ERR] 标记,方便区分两个流。

日志文件名带 pid ​

csharp
var logFile = Path.Combine(LauncherPaths.GameLogsDir, $"game-{DateTime.Now:yyyyMMdd-HHmmss}-{p.Id}.log");

LauncherPaths.GameLogsDir 是 <启动器数据目录>/logs/game/。

为什么带 pid

同秒多开时秒级时间戳会撞名——两个实例都写成 game-20260926-153012.log,后写的覆盖先写的。pid 是唯一不会重复的标识,所以文件名是 game-yyyyMMdd-HHmmss-<pid>.log。这也顺带让"这局日志是哪个文件的"可以由 pid 直接推出来,恢复路径的注册文件里记的就是它。

退出收口 ​

退出码只在 WaitForExitAsync 之后读:

csharp
try { await _process.WaitForExitAsync().ConfigureAwait(false); } catch { }
try { _exitCode = _process.ExitCode; } catch { }

在进程没退出时读 ExitCode 不可靠,所以 GameSession.ExitCode 的注释写明用法:

csharp
/// <summary>退出码(进程未退出时读会回退到 0;调用方应先判断 <see cref="HasExited"/>)</summary>

两条 pump 收尾才算完 ​

csharp
// 等两条读流自然收尾,确保崩溃诊断能拿到完整尾部输出
try { await Task.WhenAll(_stdoutPump, _stderrPump).ConfigureAwait(false); } catch { }

_hasExited = true;
_exitedAt = DateTime.Now;

顺序很关键:先 WaitForExitAsync(进程结束),再等两条 pump 都读到流末尾(EOF),最后才置 _hasExited 并抛 Exited 事件。

如果不等 pump 就置已退出,崩溃瞬间写进 stderr 的最后几行——通常是 Exception in thread "main" 和堆栈——可能还没被读出来。崩溃诊断恰恰要的就是这几行。

Exited 事件用 Interlocked.Exchange 保证只抛一次:

csharp
if (Interlocked.Exchange(ref _exitedRaised, 1) == 0)
{
    try { Exited?.Invoke(_exitCode); } catch { }
}

多开时事件本身不携带会话标识,所以订阅方必须用按 pid 绑定的闭包来分辨是哪一局退出了:

csharp
// 事件不带会话标识:必须用按 pid 绑定的闭包匹配,不能靠 sender
Action<int> handler = code => OnSessionExited(pid, code);

日志文件怎么落盘 ​

csharp
var stream = new FileStream(logFile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
_writer = new StreamWriter(stream, new UTF8Encoding(false)) { AutoFlush = true };

三个细节:Append 模式让同名文件不丢已有内容;FileShare.ReadWrite 允许用户或崩溃诊断在游戏运行期间读这个文件;AutoFlush 保证每行立即落盘——否则游戏崩了缓冲区里的日志就没了。

打开失败要降级而不是崩

游戏正在启动,日志文件打不开(杀毒软件锁、权限问题)不该让整个启动失败。这段包在 try/catch 里,失败时 _writer = null,日志功能整体降级为"只有内存缓冲"。

相关链接:从点击到游戏起来 · 防双开与运行态 · 启动参数怎么构建

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