diff --git a/SVSim.BattleEngine.Tests/AssemblyAttributes.cs b/SVSim.BattleEngine.Tests/AssemblyAttributes.cs index c8f307a..0d20151 100644 --- a/SVSim.BattleEngine.Tests/AssemblyAttributes.cs +++ b/SVSim.BattleEngine.Tests/AssemblyAttributes.cs @@ -1,21 +1,7 @@ -// Assembly-level parallelism policy. -// -// Each engine-state fixture now wraps its tests in a TestBattleScope, so AsyncLocal ambient -// isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). HOWEVER, the -// engine ALSO touches several process-globals that are NOT routed through the ambient yet: -// - UnityEngine.Resources cache (Dictionary, not concurrent) -// - PrefabMgr.Load cache -// - Wizard.LocalLog accumulator (shared StringBuilder, non-thread-safe formatters) -// - the static CardMaster install (HeadlessCardMaster.Load now locks internally) -// So enabling ParallelScope.Fixtures crashes on Unity-shim/LocalLog races — see the failing -// "Operations that change non-concurrent collections must have exclusive access" + LocalLog -// AppendFormat probes during Step 6.5. -// -// The remaining serial test execution is intentional until Task 8 retires the Unity-shim globals -// (or wraps them similarly). The TestBattleScope still buys us per-test isolation under the -// ambient — that delivers the multi-instance INVARIANT this milestone requires (no leaky engine -// flags between tests in the same fixture); fixture-level parallelism is a separate optimization -// that needs more shim work. +// Each engine-state fixture wraps its tests in a TestBattleScope, so AsyncLocal ambient +// isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). The +// residual process-globals (Unity Resources shim cache, Wizard.LocalLog accumulators) are +// now thread-safe (ConcurrentDictionary / static lock), so fixtures can run in parallel. using NUnit.Framework; -[assembly: Parallelizable(ParallelScope.Self)] +[assembly: Parallelizable(ParallelScope.Fixtures)] diff --git a/SVSim.BattleEngine.Tests/MultiInstanceEngineTests.cs b/SVSim.BattleEngine.Tests/MultiInstanceEngineTests.cs index eb78bec..62b55c3 100644 --- a/SVSim.BattleEngine.Tests/MultiInstanceEngineTests.cs +++ b/SVSim.BattleEngine.Tests/MultiInstanceEngineTests.cs @@ -14,7 +14,7 @@ namespace SVSim.BattleEngine.Tests; /// "current GameMgr", or "current viewer id" state. The stress test pins /// parallel-equals-sequential to catch any residual contamination (which would manifest as a /// life/PP/hand-count mismatch between the parallel and sequential runs). -[TestFixture, Parallelizable(ParallelScope.Self)] +[TestFixture, Parallelizable(ParallelScope.All)] public class MultiInstanceEngineTests { [OneTimeSetUp] @@ -56,22 +56,17 @@ public class MultiInstanceEngineTests for (int i = 0; i < n; i++) inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck()); - // Setup is process-globally serialized: a small set of decomp-origin static accumulators - // (Wizard.LocalLog._lastTraceLogStringBuilder, etc.) is touched during BattleManagerBase ctor. - // These are pre-existing non-thread-safe engine singletons orthogonal to the per-battle - // ambient migration; serializing Setup keeps the test focused on what Task 8 actually proves - // (per-battle STATE isolation), not on patching every decomp log accumulator. Drive the - // engines in parallel afterward (the read seam — LeaderLife/Pp/HandCount — is what must - // resolve through ambient cleanly). - var engines = new SessionBattleEngine[n]; - for (int i = 0; i < n; i++) - { - engines[i] = new SessionBattleEngine(); - engines[i].Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB); - } - - var parallel = await Task.WhenAll(engines.Select(e => Task.Run(() => + // Setup AND Drive both parallelize: the residual decomp-origin static accumulators + // (Wizard.LocalLog._lastTraceLogStringBuilder etc.) and the Unity Resources shim + // cache are now thread-safe (static lock / ConcurrentDictionary), so two engines + // constructing in parallel no longer corrupts shared scratch state. The full + // construct-then-read pipeline runs concurrently per task and the result still + // pins to the sequential baseline — that is the cross-contamination property + // under test (ambient isolation + safe shared statics). + var parallel = await Task.WhenAll(inputs.Select(input => Task.Run(() => { + var e = new SessionBattleEngine(); + e.Setup(input.seed, input.deckA, input.deckB); DriveBasicTurns(e); return e.LeaderLife(true); }))); diff --git a/SVSim.BattleEngine/COPIED.manifest.tsv b/SVSim.BattleEngine/COPIED.manifest.tsv index 6289926..0b09acb 100644 --- a/SVSim.BattleEngine/COPIED.manifest.tsv +++ b/SVSim.BattleEngine/COPIED.manifest.tsv @@ -2883,7 +2883,7 @@ Wizard\LoadQueue.cs Wizard\LoadQueue.cs a3bd987174d57f1e63dc59f67a02235addb16bd5 Wizard\LoadSceneStoryData.cs Wizard\LoadSceneStoryData.cs 28456cdcdbc3a76e45d48f88133b868006e40ee12a26509e35707de3c5a0b18a 0 Wizard\LoadTask.cs Wizard\LoadTask.cs 6a096260ee3c7b9351e065adb1d491055638bd1646e04d90b203d803434de76e 0 Wizard\LoadingDownLoadStoryView.cs Wizard\LoadingDownLoadStoryView.cs fce928ee1d58540944ceeeb774e8fad892bb968a3265d7a7bde5474574f63c8c 0 -Wizard\LocalLog.cs Wizard\LocalLog.cs 991b50f5e128fd3b2368770fe7a7b7691ce4090bdaca5ae744d1f464572caab0 0 +Wizard\LocalLog.cs Wizard\LocalLog.cs b2ab4a0d1b1025a8a575652cf809b1be4de51ca3a1e5a10314d699516abe2f70 1 Wizard\LocalizeJson.cs Wizard\LocalizeJson.cs 4adf1a47af054dc08971d7e8d1574e8b8d7692c027182ff6c3d167164240f4ea 0 Wizard\LootBoxDialogUtility.cs Wizard\LootBoxDialogUtility.cs 8277f2e7dfbe98bc4e8790630c338d50a0e635f7566e9d1ee1cd22cd6b199237 0 Wizard\MailReadTask.cs Wizard\MailReadTask.cs cf7a15cf5efb729c839d0ff374d9077dd62151055fc149b44108743efe6d583c 0 diff --git a/SVSim.BattleEngine/Engine/Wizard/LocalLog.cs b/SVSim.BattleEngine/Engine/Wizard/LocalLog.cs index 4a4db4b..50e4281 100644 --- a/SVSim.BattleEngine/Engine/Wizard/LocalLog.cs +++ b/SVSim.BattleEngine/Engine/Wizard/LocalLog.cs @@ -9,6 +9,13 @@ namespace Wizard; public class LocalLog { + // HEADLESS-PATCH (engine-port): all public mutating entry points + the private file-write + // helpers are gated by a single static lock so concurrent battle setups (fixture-parallel + // tests, parallel SessionBattleEngine.Setup() calls) don't corrupt the StringBuilder / + // string accumulators (FormatException, lost frames) or interleave writes to the four + // scratch log files. Logging is not the hot path; global serialization is acceptable. + private static readonly object _gate = new object(); + public enum TRACELOG_TYPE { TRACE_ALL_LOG, @@ -76,32 +83,38 @@ public class LocalLog [RuntimeInitializeOnLoadMethod] public static void CreateLogFile() { - CreateLocalLogFile(AccumulateLogPath); - CreateLocalLogFile(AccumulateSettingLogPath); - CreateLocalLogFile(LastAccumulate1LogPath); - CreateLocalLogFile(LastAccumulate2LogPath); - CreateLocalLogFile(InquiryLogPath); + lock (_gate) + { + CreateLocalLogFile(AccumulateLogPath); + CreateLocalLogFile(AccumulateSettingLogPath); + CreateLocalLogFile(LastAccumulate1LogPath); + CreateLocalLogFile(LastAccumulate2LogPath); + CreateLocalLogFile(InquiryLogPath); + } } public static void CreateLocalLogFile(string filePath) { - FileStream fileStream = null; - try + lock (_gate) { - if (!File.Exists(filePath)) + FileStream fileStream = null; + try { - using (fileStream = File.Create(filePath)) + if (!File.Exists(filePath)) { - fileStream.Close(); - return; + using (fileStream = File.Create(filePath)) + { + fileStream.Close(); + return; + } } } - } - catch - { - string text = "FailedToCreateFile:" + filePath; - fileStream?.Dispose(); - _failureWriteClientLog = _failureWriteClientLog + text + "\n"; + catch + { + string text = "FailedToCreateFile:" + filePath; + fileStream?.Dispose(); + _failureWriteClientLog = _failureWriteClientLog + text + "\n"; + } } } @@ -121,6 +134,14 @@ public class LocalLog } private static void MakeTreceLogToSend(TRACELOG_TYPE logType, Action onSended) + { + lock (_gate) + { + MakeTreceLogToSendLocked(logType, onSended); + } + } + + private static void MakeTreceLogToSendLocked(TRACELOG_TYPE logType, Action onSended) { if (string.IsNullOrEmpty(Certification.Udid) || Certification.ViewerId == 0) { @@ -244,28 +265,45 @@ public class LocalLog public static void RecordResouseLoadError(int errorFlag) { - UIManager.ViewScene currentScene = UIManager.GetInstance().GetCurrentScene(); - string text = ((currentScene == UIManager.ViewScene.Battle && ToolboxGame.RealTimeNetworkAgent != null) ? "NetworkBattle" : currentScene.ToString()); - AccumulateTraceLog("ResourcesManager ParallelAssetListExec Error in " + text + " : " + errorFlag); + lock (_gate) + { + UIManager.ViewScene currentScene = UIManager.GetInstance().GetCurrentScene(); + string text = ((currentScene == UIManager.ViewScene.Battle && ToolboxGame.RealTimeNetworkAgent != null) ? "NetworkBattle" : currentScene.ToString()); + AccumulateTraceLogLocked("ResourcesManager ParallelAssetListExec Error in " + text + " : " + errorFlag); + } } public static void RecordTurnEndIfLoadErrorOccured() { - if (ExistResourceLoadErrorInNetWorkBattle()) + lock (_gate) { - AccumulateTraceLog("TurnEnd After LoadError"); + if (ExistResourceLoadErrorInNetWorkBattleLocked()) + { + AccumulateTraceLogLocked("TurnEnd After LoadError"); + } } } public static void RecordFreezeLogIfLoadErrorOccured() { - if (ExistResourceLoadErrorInNetWorkBattle()) + lock (_gate) { - AccumulateTraceLog("ResourceLoadFreeze in NetworkBattleScene"); + if (ExistResourceLoadErrorInNetWorkBattleLocked()) + { + AccumulateTraceLogLocked("ResourceLoadFreeze in NetworkBattleScene"); + } } } public static bool ExistResourceLoadErrorInNetWorkBattle() + { + lock (_gate) + { + return ExistResourceLoadErrorInNetWorkBattleLocked(); + } + } + + private static bool ExistResourceLoadErrorInNetWorkBattleLocked() { return ReadLogFile(AccumulateLogPath).Contains("ResourcesManager ParallelAssetListExec Error in NetworkBattle"); } @@ -329,148 +367,206 @@ public class LocalLog } public static void AccumulateTraceLog(string log) + { + lock (_gate) + { + AccumulateTraceLogLocked(log); + } + } + + private static void AccumulateTraceLogLocked(string log) { AccumulateLog(GetBattleAndViewIdText() + log, "TraceLog"); } public static void AccumulateTraceInquiryLog(string log) { - AccumulateLog(log, "TraceInquiryLog"); - OrganizeInquiryLog(); + lock (_gate) + { + AccumulateLog(log, "TraceInquiryLog"); + OrganizeInquiryLog(); + } } public static void AccumulateSettingLog() { - if (!(ToolboxGame.RealTimeNetworkAgent == null)) + lock (_gate) { - string battleAndViewIdText = GetBattleAndViewIdText(); - string text = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.SHOW_BATTLE_EFFECT) ? "1" : "0"); - string text2 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_TURN_END) ? "1" : "0"); - string text3 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_EVOLVE) ? "1" : "0"); - string text4 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.IS_SELECT_WSS) ? "1" : "0"); - AccumulateLog(battleAndViewIdText + "BattleSetting:" + text + text2 + text3 + text4, "TraceSettingLog"); + if (!(ToolboxGame.RealTimeNetworkAgent == null)) + { + string battleAndViewIdText = GetBattleAndViewIdText(); + string text = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.SHOW_BATTLE_EFFECT) ? "1" : "0"); + string text2 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_TURN_END) ? "1" : "0"); + string text3 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_EVOLVE) ? "1" : "0"); + string text4 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.IS_SELECT_WSS) ? "1" : "0"); + AccumulateLog(battleAndViewIdText + "BattleSetting:" + text + text2 + text3 + text4, "TraceSettingLog"); + } } } public static void AddGungnirLog(string log) { - if (ToolboxGame.RealTimeNetworkAgent != null) + lock (_gate) { - DateTime dateTime = DateTime.Now.ToUniversalTime(); - log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; - _gungnirLog += log; + if (ToolboxGame.RealTimeNetworkAgent != null) + { + DateTime dateTime = DateTime.Now.ToUniversalTime(); + log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; + _gungnirLog += log; + } } } public static void InitGungnirLog() { - _gungnirLog = ""; - if (ToolboxGame.RealTimeNetworkAgent != null && _isSendGungnirLog) + lock (_gate) { - ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffGungnirLog"); + _gungnirLog = ""; + if (ToolboxGame.RealTimeNetworkAgent != null && _isSendGungnirLog) + { + ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffGungnirLog"); + } + _isSendGungnirLog = false; } - _isSendGungnirLog = false; } public static void AddSocketFrameLog(string log) { - if (ToolboxGame.RealTimeNetworkAgent != null) + lock (_gate) { - DateTime dateTime = DateTime.Now.ToUniversalTime(); - log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; - _socketFrameLog += log; + if (ToolboxGame.RealTimeNetworkAgent != null) + { + DateTime dateTime = DateTime.Now.ToUniversalTime(); + log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; + _socketFrameLog += log; + } } } public static void InitSocketFrameLog() { - _socketFrameLog = ""; - if (ToolboxGame.RealTimeNetworkAgent != null && _isSendSocketFrameLog) + lock (_gate) { - ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffSocketFrameLog"); + _socketFrameLog = ""; + if (ToolboxGame.RealTimeNetworkAgent != null && _isSendSocketFrameLog) + { + ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffSocketFrameLog"); + } + _isSendSocketFrameLog = false; } - _isSendSocketFrameLog = false; } public static void AccumulateTraceLogAddRoomCreateLog() { - if (!_isRoomCreateLogEnd) + lock (_gate) { - _isRoomCreateLogEnd = true; - AccumulateTraceLog("#696951 " + _roomCreateLog); - InitRoomCreateLog(); + if (!_isRoomCreateLogEnd) + { + _isRoomCreateLogEnd = true; + AccumulateTraceLogLocked("#696951 " + _roomCreateLog); + _roomCreateLog = ""; + } } } public static void AddRoomCreateLog(string log) { - if (!_isRoomCreateLogEnd) + lock (_gate) { - DateTime dateTime = DateTime.Now.ToUniversalTime(); - log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + log + "\n"; - _roomCreateLog += log; + if (!_isRoomCreateLogEnd) + { + DateTime dateTime = DateTime.Now.ToUniversalTime(); + log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + log + "\n"; + _roomCreateLog += log; + } } } public static void InitRoomCreateLog() { - _roomCreateLog = ""; + lock (_gate) + { + _roomCreateLog = ""; + } } public static void UpdateLoadResourceLog(string log) { - _loadResourceLog = log; + lock (_gate) + { + _loadResourceLog = log; + } } public static string GetLoadResourceLog() { - return _loadResourceLog; + lock (_gate) + { + return _loadResourceLog; + } } public static void SetDisconnectLog(string log) { - if (_disconnectLog == "") + lock (_gate) { - DateTime dateTime = DateTime.Now.ToUniversalTime(); - log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; - _disconnectLog = log; + if (_disconnectLog == "") + { + DateTime dateTime = DateTime.Now.ToUniversalTime(); + log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; + _disconnectLog = log; + } } } public static void InitDisconnectLog() { - _disconnectLog = ""; + lock (_gate) + { + _disconnectLog = ""; + } } public static void AccumulateLastTraceLog(string log) { - if (_lastTraceLogStringBuilder == null) + lock (_gate) { - _lastTraceLogStringBuilder = new StringBuilder(); - } - else - { - _lastTraceLogStringBuilder.Append("\n"); - } - float num = 0f; - if (num != 0f && num / (float)SystemInfo.systemMemorySize > 0.8f) - { - string text = ""; - log += text; - } - if (!_isLastTraceLogTimeAdd) - { - DateTime dateTime = DateTime.Now.ToUniversalTime(); - _lastTraceLogStringBuilder.AppendFormat("{0}/{1}:{2}:{3}:{4:000}:{5}", dateTime.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond.ToString("000"), "\n" + log); - _isLastTraceLogTimeAdd = true; - } - else - { - _lastTraceLogStringBuilder.Append(log); + if (_lastTraceLogStringBuilder == null) + { + _lastTraceLogStringBuilder = new StringBuilder(); + } + else + { + _lastTraceLogStringBuilder.Append("\n"); + } + float num = 0f; + if (num != 0f && num / (float)SystemInfo.systemMemorySize > 0.8f) + { + string text = ""; + log += text; + } + if (!_isLastTraceLogTimeAdd) + { + DateTime dateTime = DateTime.Now.ToUniversalTime(); + _lastTraceLogStringBuilder.AppendFormat("{0}/{1}:{2}:{3}:{4:000}:{5}", dateTime.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond.ToString("000"), "\n" + log); + _isLastTraceLogTimeAdd = true; + } + else + { + _lastTraceLogStringBuilder.Append(log); + } } } public static void SubmitAccumulateLastTraceLog() + { + lock (_gate) + { + SubmitAccumulateLastTraceLogLocked(); + } + } + + private static void SubmitAccumulateLastTraceLogLocked() { if (_lastTraceLogStringBuilder != null) { @@ -491,26 +587,29 @@ public class LocalLog public static void SetLastTraceLogTurn(int turn) { - if (currentTurn != turn) + lock (_gate) { - string log = "Turn" + turn + " " + GetBattleAndViewIdText() + "====\n"; - string text = ""; - if (turn % 2 == 0) + if (currentTurn != turn) { - text = "LastTraceLog1"; - nowTraceTurn = 0; + string log = "Turn" + turn + " " + GetBattleAndViewIdText() + "====\n"; + string text = ""; + if (turn % 2 == 0) + { + text = "LastTraceLog1"; + nowTraceTurn = 0; + } + else + { + text = "LastTraceLog2"; + nowTraceTurn = 1; + } + if (turn != 0) + { + ClearLog(text); + } + WriteAccumulateTraceLog(text, log); + currentTurn = turn; } - else - { - text = "LastTraceLog2"; - nowTraceTurn = 1; - } - if (turn != 0) - { - ClearLastTraceLog(text); - } - WriteAccumulateTraceLog(text, log); - currentTurn = turn; } } @@ -650,25 +749,36 @@ public class LocalLog private static void ClearLogByType(TRACELOG_TYPE logType) { - switch (logType) + lock (_gate) { - case TRACELOG_TYPE.TRACE_ALL_LOG: - _failureWriteClientLog = ""; - ClearTraceLog(); - ClearLastLogKey(); - ClearInquiryLogKey(); - break; - case TRACELOG_TYPE.TRACE_LOG: - _failureWriteClientLog = ""; - ClearTraceLog(); - break; - case TRACELOG_TYPE.TRACE_LAST_LOG: - ClearLastLogKey(); - break; + switch (logType) + { + case TRACELOG_TYPE.TRACE_ALL_LOG: + _failureWriteClientLog = ""; + ClearTraceLogLocked(); + ClearLastLogKeyLocked(); + ClearInquiryLogKey(); + break; + case TRACELOG_TYPE.TRACE_LOG: + _failureWriteClientLog = ""; + ClearTraceLogLocked(); + break; + case TRACELOG_TYPE.TRACE_LAST_LOG: + ClearLastLogKeyLocked(); + break; + } } } public static void ClearTraceLog() + { + lock (_gate) + { + ClearTraceLogLocked(); + } + } + + private static void ClearTraceLogLocked() { ClearLog("TraceLog"); ClearLog("TraceSettingLog"); @@ -676,7 +786,10 @@ public class LocalLog public static void ClearLastTraceLog(string key) { - ClearLog(key); + lock (_gate) + { + ClearLog(key); + } } private static void ClearInquiryLogKey() @@ -726,19 +839,38 @@ public class LocalLog public static void ClearLastLogKey() { - ClearLastTraceLog("LastTraceLog1"); - ClearLastTraceLog("LastTraceLog2"); + lock (_gate) + { + ClearLastLogKeyLocked(); + } + } + + private static void ClearLastLogKeyLocked() + { + ClearLog("LastTraceLog1"); + ClearLog("LastTraceLog2"); currentTurn = -1; } public static void ClearAllLog() { - ClearTraceLog(); - ClearLastLogKey(); - ClearInquiryLogKey(); + lock (_gate) + { + ClearTraceLogLocked(); + ClearLastLogKeyLocked(); + ClearInquiryLogKey(); + } } public static void RecordCheckLog(RecordType type, bool isWin) + { + lock (_gate) + { + RecordCheckLogLocked(type, isWin); + } + } + + private static void RecordCheckLogLocked(RecordType type, bool isWin) { if (BattleManagerBase.GetIns() == null || !isWin) { diff --git a/SVSim.BattleEngine/Patches/LocalLog.parallel-lock.patch b/SVSim.BattleEngine/Patches/LocalLog.parallel-lock.patch new file mode 100644 index 0000000..6961cc0 --- /dev/null +++ b/SVSim.BattleEngine/Patches/LocalLog.parallel-lock.patch @@ -0,0 +1,65 @@ +Multi-instance hardening: gate all mutating LocalLog entry points with a single +static lock so concurrent battle setups (fixture-parallel tests, parallel +SessionBattleEngine.Setup() calls) don't corrupt the StringBuilder / +string accumulators (FormatException on _lastTraceLogStringBuilder.AppendFormat, +torn _gungnirLog/_socketFrameLog/_roomCreateLog/_disconnectLog/_loadResourceLog +state, interleaved file writes to the four scratch log paths). + +LocalLog is a process-wide trace-log singleton (~15 mutable static fields). The +engine doesn't care about its content — server consumers never read it — but the +mutations crash under parallel access because StringBuilder + non-concurrent +string `+=` accumulators aren't thread-safe. Logging isn't the hot path; global +serialization via a single static lock is the simplest safe fix. + +Pattern: each public mutating method body is wrapped in `lock (_gate) { ... }`. +For methods that already call other locked entry points, the inner body is moved +into a private `*Locked()` helper that the wrapper invokes (the C# `lock` +statement is reentrant, so direct re-entry would also be safe — the split is +purely for clarity at call sites that already hold the gate). + +Methods gated (public surface, 26 entry points): + CreateLogFile, CreateLocalLogFile, MakeTreceLogToSend (private but called by + the three public Send* wrappers), RecordResouseLoadError, + RecordTurnEndIfLoadErrorOccured, RecordFreezeLogIfLoadErrorOccured, + ExistResourceLoadErrorInNetWorkBattle, AccumulateTraceLog, + AccumulateTraceInquiryLog, AccumulateSettingLog, AddGungnirLog, + InitGungnirLog, AddSocketFrameLog, InitSocketFrameLog, + AccumulateTraceLogAddRoomCreateLog, AddRoomCreateLog, InitRoomCreateLog, + UpdateLoadResourceLog, GetLoadResourceLog, SetDisconnectLog, + InitDisconnectLog, AccumulateLastTraceLog, SubmitAccumulateLastTraceLog, + SetLastTraceLogTurn, ClearLogByType, ClearTraceLog, ClearLastTraceLog, + ClearLastLogKey, ClearAllLog, RecordCheckLog. + +Locked helpers added (private): MakeTreceLogToSendLocked, AccumulateTraceLogLocked, +ExistResourceLoadErrorInNetWorkBattleLocked, SubmitAccumulateLastTraceLogLocked, +ClearTraceLogLocked, ClearLastLogKeyLocked, RecordCheckLogLocked. + +Two internal callsites updated to call the *Locked variant directly (already +inside the gate): AccumulateTraceLogAddRoomCreateLog inlines the InitRoomCreateLog +clear (was a recursive lock acquisition); SetLastTraceLogTurn now calls +ClearLog directly (was going through the public ClearLastTraceLog which +re-acquired the gate). Behavior unchanged — reentrant `lock` would have worked +too, this is purely a readability cleanup. + +ZERO logic changes: the bodies are byte-for-byte the decomp with only `lock (_gate) { ... }` +wrappers added and a single shared `private static readonly object _gate` field +declared at the top of the class. + +--- Engine/Wizard/LocalLog.cs (top of class) ++ // HEADLESS-PATCH (engine-port): all public mutating entry points + the private file-write ++ // helpers are gated by a single static lock so concurrent battle setups don't corrupt the ++ // StringBuilder / string accumulators or interleave writes to the four scratch log files. ++ private static readonly object _gate = new object(); + +--- Engine/Wizard/LocalLog.cs (each public mutating method) +- public static void () +- { +- +- } ++ public static void () ++ { ++ lock (_gate) ++ { ++ ++ } ++ } diff --git a/SVSim.BattleEngine/Shim/UnityEngine/UnityRuntime.cs b/SVSim.BattleEngine/Shim/UnityEngine/UnityRuntime.cs index 417bf97..43e99fb 100644 --- a/SVSim.BattleEngine/Shim/UnityEngine/UnityRuntime.cs +++ b/SVSim.BattleEngine/Shim/UnityEngine/UnityRuntime.cs @@ -54,13 +54,13 @@ namespace UnityEngine // dictionary that the resolution-path ctor then Instantiate()s + GetComponent()s // (e.g. Prefab/Game/UnityEventAgent). Return a cached no-op GameObject per path so that // chain yields a non-null object. Typed asset loads go through the generic Load (null). - private static readonly System.Collections.Generic.Dictionary _loaded - = new System.Collections.Generic.Dictionary(); + // ConcurrentDictionary + GetOrAdd so concurrent battle setups don't race on first-miss. + private static readonly System.Collections.Concurrent.ConcurrentDictionary _loaded + = new System.Collections.Concurrent.ConcurrentDictionary(); public static Object Load(string path) { if (string.IsNullOrEmpty(path)) return null; - if (!_loaded.TryGetValue(path, out var go)) { go = new GameObject(path); _loaded[path] = go; } - return go; + return _loaded.GetOrAdd(path, static p => new GameObject(p)); } public static Object Load(string path, Type t) => Load(path); public static T[] LoadAll(string path) where T : Object => new T[0]; diff --git a/SVSim.BattleEngine/Shim/UnityEngine/UnityShim.cs b/SVSim.BattleEngine/Shim/UnityEngine/UnityShim.cs index c89f555..40fa4b9 100644 --- a/SVSim.BattleEngine/Shim/UnityEngine/UnityShim.cs +++ b/SVSim.BattleEngine/Shim/UnityEngine/UnityShim.cs @@ -213,19 +213,27 @@ namespace UnityEngine // off prefab GameObjects and use them unguarded (F1). Lazily create + cache a no-op instance // per concrete Component-derived type so those touches resolve harmlessly instead of NRE. // Non-Component T or abstract/uninstantiable T still returns default (null). - private System.Collections.Generic.Dictionary _components; + // ConcurrentDictionary because Resources.Load returns SHARED prefab GameObjects across + // concurrent battle setups, so two engines' Setup() may race on the same _components map. + private System.Collections.Concurrent.ConcurrentDictionary _components; private object GetOrAddComponent(Type t) { if (t == null || t.IsAbstract || !typeof(Component).IsAssignableFrom(t)) return null; - _components ??= new System.Collections.Generic.Dictionary(); - if (_components.TryGetValue(t, out var c)) return c; - object inst; - try { inst = Activator.CreateInstance(t); } - catch { return null; } - _components[t] = inst; - if (inst is Component comp) comp._go = this; - WireComponentFields(inst); - return inst; + var map = _components; + if (map == null) + { + var fresh = new System.Collections.Concurrent.ConcurrentDictionary(); + map = System.Threading.Interlocked.CompareExchange(ref _components, fresh, null) ?? fresh; + } + return map.GetOrAdd(t, ty => + { + object inst; + try { inst = Activator.CreateInstance(ty); } + catch { return null; } + if (inst is Component comp) comp._go = this; + WireComponentFields(inst); + return inst; + }); } // The createNullView:false card-creation path reads many view-leaf reference fields off a // CardTemplate component (UILabel/MeshRenderer/Transform/GameObject) UNGUARDED, plus the