179 lines
6.9 KiB
C#
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 [];
|
|
}
|
|
}
|
|
}
|