Files

179 lines
6.9 KiB
C#

using System.Collections.Concurrent;
using Ananta.Server.Configuration;
namespace Ananta.Server.Protocol.Client4229938;
/// <summary>
/// Durable log of every RPC method the server has no semantic handler for yet.
/// Writes to logs/unknown-methods.log so future work can replay shapes
/// (method id + name, body length, hex prefix). Return behavior of callers is unchanged.
/// </summary>
/// <remarks>
/// ⛑ 2026-09-30 加按 methodId 节流(原因见下)。此前是「一次调用一行」,
/// 而实机证明高频 Notify 会把这里打成洪水:AskOnMetroEnterStation 累计 4.8 万次、
/// AskOnMetroExitStation 4.7 万次、ReportSetActionGroup 2.4 万次、AskTriggerPlotEvent 1.3 万次,
/// 日志涨到 39 MB 且每次调用都要 lock + File.AppendAllText(开-写-关)。
/// 现在:每个 id 首次必写完整行(保留取证/差集能力),
/// 之后在 <see cref="RepeatFlushSeconds"/> 窗口内只累加计数不落盘,
/// 窗口到点写一条 REPEAT 汇总。落盘次数从 O(调用数) 降到 O(id 数 × 时长/窗口)。
/// </remarks>
internal static class UnknownMethodLogger
{
private static readonly object SyncRoot = new();
private static string? _logPath;
/// <summary>Maximum body bytes rendered as hex per line (full length is always recorded).</summary>
internal const int MaxHexBytesPerLine = 256;
/// <summary>同一个 methodId 两次落盘之间的最小间隔(秒)。窗口内只累加计数。</summary>
internal const int RepeatFlushSeconds = 60;
private sealed class ThrottleState
{
/// <summary>自上次落盘以来累计的调用次数。</summary>
public int Pending;
/// <summary>上次真正落盘的时间(UTC)。仅在 <see cref="SyncRoot"/> 内读写。</summary>
public DateTimeOffset LastWriteUtc;
/// <summary>是否已写过首行(首行含 hex,是取证关键)。仅在 <see cref="SyncRoot"/> 内写。</summary>
public bool Seen;
/// <summary><see cref="Seen"/> 的 volatile 镜像,用于无锁快路径判定。</summary>
public int SeenFlag;
}
private static readonly ConcurrentDictionary<uint, ThrottleState> States = new();
internal static void Log(string scope, string kind, uint methodId, byte[] body)
{
try
{
var state = States.GetOrAdd(methodId, _ => new ThrottleState());
// 先无条件累加;是否落盘由下面决定。
Interlocked.Increment(ref state.Pending);
var now = DateTimeOffset.UtcNow;
// 无锁快路径:非首次且还在窗口内 ⇒ 直接返回,既不构造字符串也不碰文件。
if (Volatile.Read(ref state.SeenFlag) != 0 &&
(now - state.LastWriteUtc).TotalSeconds < RepeatFlushSeconds)
{
return;
}
var path = LogPath;
var name = Ananta.SDK.Logging.RpcMethodNames.Display(methodId);
var stamp = now.ToString("o");
string line;
lock (SyncRoot)
{
// 双重检查:并发下可能已有别的线程刚写过。
if (state.Seen &&
(DateTimeOffset.UtcNow - state.LastWriteUtc).TotalSeconds < RepeatFlushSeconds)
{
return;
}
var pending = Interlocked.Exchange(ref state.Pending, 0);
if (!state.Seen)
{
var shown = Math.Min(body.Length, MaxHexBytesPerLine);
var hex = shown == 0 ? "-" : Convert.ToHexString(body, 0, shown);
if (body.Length > shown)
hex += "...";
line = $"{stamp} {kind} {name} id={methodId} bytes={body.Length} hex={hex}{Environment.NewLine}";
state.Seen = true;
Volatile.Write(ref state.SeenFlag, 1);
}
else
{
line = $"{stamp} REPEAT {kind} {name} id={methodId} x{pending} " +
$"since {state.LastWriteUtc:o}{Environment.NewLine}";
}
File.AppendAllText(path, line);
state.LastWriteUtc = DateTimeOffset.UtcNow;
}
}
catch
{
// Logging must never break the game session.
}
}
private static string LogPath
{
get
{
if (_logPath is not null)
return _logPath;
lock (SyncRoot)
{
if (_logPath is not null)
return _logPath;
string dir;
try
{
dir = PrivateServerConfigStore.ResolveProjectPath(
PrivateServerConfigStore.Current.Logging.Directory);
}
catch
{
dir = Path.Combine(AppContext.BaseDirectory, "logs");
}
Directory.CreateDirectory(dir);
_logPath = Path.Combine(dir, "unknown-methods.log");
return _logPath;
}
}
}
/// <summary>从文件尾部读取的字节数(避免把整个 39MB 日志读进内存)。</summary>
internal const int TailReadBytes = 64 * 1024;
/// <summary>Reads the last N lines for the admin panel tail viewer.</summary>
/// <remarks>
/// ⛑ 2026-09-30:原来直接 `File.ReadAllLines(path)` 整文件读。日志涨到 39 MB 后,
/// 管理面板每刷一次就要把 39 MB 全读进内存再切片。改成 Seek 到尾部只读最后 64 KB。
/// </remarks>
internal static string[] Tail(int maxLines)
{
try
{
var path = LogPath;
if (!File.Exists(path))
return [];
var length = new FileInfo(path).Length;
if (length <= TailReadBytes)
{
var all = File.ReadAllLines(path);
return all.Length <= maxLines ? all : all[^maxLines..];
}
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
fs.Seek(-TailReadBytes, SeekOrigin.End);
using var reader = new StreamReader(fs);
var chunk = reader.ReadToEnd();
var lines = chunk.Split('\n');
// 首行几乎必然被从中间截断 ⇒ 丢弃,避免给面板喂半行。
if (lines.Length > 1)
lines = lines[1..];
// StreamReader 会把 "\r\n" 归一化,这里顺手去掉残留的 \r。
for (var i = 0; i < lines.Length; i++)
lines[i] = lines[i].TrimEnd('\r');
if (lines.Length > 0 && lines[^1].Length == 0)
lines = lines[..^1];
return lines.Length <= maxLines ? lines : lines[^maxLines..];
}
catch
{
return [];
}
}
}