using System.Collections.Concurrent; using Ananta.Server.Configuration; namespace Ananta.Server.Protocol.Client4229938; /// /// 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. /// /// /// ⛑ 2026-09-30 加按 methodId 节流(原因见下)。此前是「一次调用一行」, /// 而实机证明高频 Notify 会把这里打成洪水:AskOnMetroEnterStation 累计 4.8 万次、 /// AskOnMetroExitStation 4.7 万次、ReportSetActionGroup 2.4 万次、AskTriggerPlotEvent 1.3 万次, /// 日志涨到 39 MB 且每次调用都要 lock + File.AppendAllText(开-写-关)。 /// 现在:每个 id 首次必写完整行(保留取证/差集能力), /// 之后在 窗口内只累加计数不落盘, /// 窗口到点写一条 REPEAT 汇总。落盘次数从 O(调用数) 降到 O(id 数 × 时长/窗口)。 /// internal static class UnknownMethodLogger { private static readonly object SyncRoot = new(); private static string? _logPath; /// Maximum body bytes rendered as hex per line (full length is always recorded). internal const int MaxHexBytesPerLine = 256; /// 同一个 methodId 两次落盘之间的最小间隔(秒)。窗口内只累加计数。 internal const int RepeatFlushSeconds = 60; private sealed class ThrottleState { /// 自上次落盘以来累计的调用次数。 public int Pending; /// 上次真正落盘的时间(UTC)。仅在 内读写。 public DateTimeOffset LastWriteUtc; /// 是否已写过首行(首行含 hex,是取证关键)。仅在 内写。 public bool Seen; /// 的 volatile 镜像,用于无锁快路径判定。 public int SeenFlag; } private static readonly ConcurrentDictionary 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; } } } /// 从文件尾部读取的字节数(避免把整个 39MB 日志读进内存)。 internal const int TailReadBytes = 64 * 1024; /// Reads the last N lines for the admin panel tail viewer. /// /// ⛑ 2026-09-30:原来直接 `File.ReadAllLines(path)` 整文件读。日志涨到 39 MB 后, /// 管理面板每刷一次就要把 39 MB 全读进内存再切片。改成 Seek 到尾部只读最后 64 KB。 /// 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 []; } } }