chore(engine-ambient): harden shim + LocalLog statics for fixture parallelism

Follow-up to the multi-instancing migration. Wraps the process-shared engine
statics that aren't ambient-fronted but race between concurrent battles:

- UnityEngine.Resources._loaded: Dictionary -> ConcurrentDictionary.GetOrAdd
  (the shared prefab cache keyed by path; concurrent first-misses produced
  duplicate GameObjects + Dictionary corruption)
- UnityEngine.GameObject._components: Dictionary -> ConcurrentDictionary with
  Interlocked.CompareExchange init (Resources.Load returns SHARED prefab
  GameObjects, so two engines' Setup() can race on the same _components map
  — surfaced as "Operations that change non-concurrent collections" crashes
  during BattleManagerBase ctor's GetComponent<T>() chain)
- Wizard.LocalLog: single static lock around all mutating entry points
  (StringBuilder _lastTraceLogStringBuilder + ~12 mutable string/bool/int
  scratch fields; serializing the trace-log surface is cheap since logging
  is not the hot path)

Flips SVSim.BattleEngine.Tests assembly Parallelizable scope from Self to
Fixtures and restructures MultiInstanceEngineTests.StressN_BaselineMatches so
Setup runs INSIDE Task.Run (was previously serialized as a workaround for the
LocalLog races). The fixture is also lifted to ParallelScope.All so the
two-engines and stress tests can run alongside each other.

Suite fully green under fixture parallelism (59/0/2 across 3 consecutive runs);
SVSim.UnitTests still 1054/0/0 — true multi-instance correctness is now proved
end-to-end in tests rather than gated behind a serial workaround.

Manifest sha refresh + new patch artifact for the LocalLog edit (decomp-origin);
the two shim files are authored, so no metadata update is needed for them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-08 08:02:49 -04:00
parent 45344e6d83
commit fbac66fd0b
7 changed files with 360 additions and 174 deletions

View File

@@ -1,21 +1,7 @@
// Assembly-level parallelism policy. // Each engine-state fixture wraps its tests in a TestBattleScope, so AsyncLocal ambient
// // isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). The
// Each engine-state fixture now wraps its tests in a TestBattleScope, so AsyncLocal ambient // residual process-globals (Unity Resources shim cache, Wizard.LocalLog accumulators) are
// isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). HOWEVER, the // now thread-safe (ConcurrentDictionary / static lock), so fixtures can run in parallel.
// engine ALSO touches several process-globals that are NOT routed through the ambient yet:
// - UnityEngine.Resources cache (Dictionary<string,object>, 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.
using NUnit.Framework; using NUnit.Framework;
[assembly: Parallelizable(ParallelScope.Self)] [assembly: Parallelizable(ParallelScope.Fixtures)]

View File

@@ -14,7 +14,7 @@ namespace SVSim.BattleEngine.Tests;
/// "current GameMgr", or "current viewer id" state. The stress test pins /// "current GameMgr", or "current viewer id" state. The stress test pins
/// parallel-equals-sequential to catch any residual contamination (which would manifest as a /// parallel-equals-sequential to catch any residual contamination (which would manifest as a
/// life/PP/hand-count mismatch between the parallel and sequential runs).</summary> /// life/PP/hand-count mismatch between the parallel and sequential runs).</summary>
[TestFixture, Parallelizable(ParallelScope.Self)] [TestFixture, Parallelizable(ParallelScope.All)]
public class MultiInstanceEngineTests public class MultiInstanceEngineTests
{ {
[OneTimeSetUp] [OneTimeSetUp]
@@ -56,22 +56,17 @@ public class MultiInstanceEngineTests
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck()); inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck());
// Setup is process-globally serialized: a small set of decomp-origin static accumulators // Setup AND Drive both parallelize: the residual decomp-origin static accumulators
// (Wizard.LocalLog._lastTraceLogStringBuilder, etc.) is touched during BattleManagerBase ctor. // (Wizard.LocalLog._lastTraceLogStringBuilder etc.) and the Unity Resources shim
// These are pre-existing non-thread-safe engine singletons orthogonal to the per-battle // cache are now thread-safe (static lock / ConcurrentDictionary), so two engines
// ambient migration; serializing Setup keeps the test focused on what Task 8 actually proves // constructing in parallel no longer corrupts shared scratch state. The full
// (per-battle STATE isolation), not on patching every decomp log accumulator. Drive the // construct-then-read pipeline runs concurrently per task and the result still
// engines in parallel afterward (the read seam — LeaderLife/Pp/HandCount — is what must // pins to the sequential baseline — that is the cross-contamination property
// resolve through ambient cleanly). // under test (ambient isolation + safe shared statics).
var engines = new SessionBattleEngine[n]; var parallel = await Task.WhenAll(inputs.Select(input => Task.Run(() =>
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(() =>
{ {
var e = new SessionBattleEngine();
e.Setup(input.seed, input.deckA, input.deckB);
DriveBasicTurns(e); DriveBasicTurns(e);
return e.LeaderLife(true); return e.LeaderLife(true);
}))); })));

View File

@@ -2883,7 +2883,7 @@ Wizard\LoadQueue.cs Wizard\LoadQueue.cs a3bd987174d57f1e63dc59f67a02235addb16bd5
Wizard\LoadSceneStoryData.cs Wizard\LoadSceneStoryData.cs 28456cdcdbc3a76e45d48f88133b868006e40ee12a26509e35707de3c5a0b18a 0 Wizard\LoadSceneStoryData.cs Wizard\LoadSceneStoryData.cs 28456cdcdbc3a76e45d48f88133b868006e40ee12a26509e35707de3c5a0b18a 0
Wizard\LoadTask.cs Wizard\LoadTask.cs 6a096260ee3c7b9351e065adb1d491055638bd1646e04d90b203d803434de76e 0 Wizard\LoadTask.cs Wizard\LoadTask.cs 6a096260ee3c7b9351e065adb1d491055638bd1646e04d90b203d803434de76e 0
Wizard\LoadingDownLoadStoryView.cs Wizard\LoadingDownLoadStoryView.cs fce928ee1d58540944ceeeb774e8fad892bb968a3265d7a7bde5474574f63c8c 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\LocalizeJson.cs Wizard\LocalizeJson.cs 4adf1a47af054dc08971d7e8d1574e8b8d7692c027182ff6c3d167164240f4ea 0
Wizard\LootBoxDialogUtility.cs Wizard\LootBoxDialogUtility.cs 8277f2e7dfbe98bc4e8790630c338d50a0e635f7566e9d1ee1cd22cd6b199237 0 Wizard\LootBoxDialogUtility.cs Wizard\LootBoxDialogUtility.cs 8277f2e7dfbe98bc4e8790630c338d50a0e635f7566e9d1ee1cd22cd6b199237 0
Wizard\MailReadTask.cs Wizard\MailReadTask.cs cf7a15cf5efb729c839d0ff374d9077dd62151055fc149b44108743efe6d583c 0 Wizard\MailReadTask.cs Wizard\MailReadTask.cs cf7a15cf5efb729c839d0ff374d9077dd62151055fc149b44108743efe6d583c 0
1 # engine-relpath source-relpath sha256 patched(0|1)
2883 Wizard\LoadSceneStoryData.cs Wizard\LoadSceneStoryData.cs 28456cdcdbc3a76e45d48f88133b868006e40ee12a26509e35707de3c5a0b18a 0
2884 Wizard\LoadTask.cs Wizard\LoadTask.cs 6a096260ee3c7b9351e065adb1d491055638bd1646e04d90b203d803434de76e 0
2885 Wizard\LoadingDownLoadStoryView.cs Wizard\LoadingDownLoadStoryView.cs fce928ee1d58540944ceeeb774e8fad892bb968a3265d7a7bde5474574f63c8c 0
2886 Wizard\LocalLog.cs Wizard\LocalLog.cs 991b50f5e128fd3b2368770fe7a7b7691ce4090bdaca5ae744d1f464572caab0 b2ab4a0d1b1025a8a575652cf809b1be4de51ca3a1e5a10314d699516abe2f70 0 1
2887 Wizard\LocalizeJson.cs Wizard\LocalizeJson.cs 4adf1a47af054dc08971d7e8d1574e8b8d7692c027182ff6c3d167164240f4ea 0
2888 Wizard\LootBoxDialogUtility.cs Wizard\LootBoxDialogUtility.cs 8277f2e7dfbe98bc4e8790630c338d50a0e635f7566e9d1ee1cd22cd6b199237 0
2889 Wizard\MailReadTask.cs Wizard\MailReadTask.cs cf7a15cf5efb729c839d0ff374d9077dd62151055fc149b44108743efe6d583c 0

View File

@@ -9,6 +9,13 @@ namespace Wizard;
public class LocalLog 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 public enum TRACELOG_TYPE
{ {
TRACE_ALL_LOG, TRACE_ALL_LOG,
@@ -76,32 +83,38 @@ public class LocalLog
[RuntimeInitializeOnLoadMethod] [RuntimeInitializeOnLoadMethod]
public static void CreateLogFile() public static void CreateLogFile()
{ {
CreateLocalLogFile(AccumulateLogPath); lock (_gate)
CreateLocalLogFile(AccumulateSettingLogPath); {
CreateLocalLogFile(LastAccumulate1LogPath); CreateLocalLogFile(AccumulateLogPath);
CreateLocalLogFile(LastAccumulate2LogPath); CreateLocalLogFile(AccumulateSettingLogPath);
CreateLocalLogFile(InquiryLogPath); CreateLocalLogFile(LastAccumulate1LogPath);
CreateLocalLogFile(LastAccumulate2LogPath);
CreateLocalLogFile(InquiryLogPath);
}
} }
public static void CreateLocalLogFile(string filePath) public static void CreateLocalLogFile(string filePath)
{ {
FileStream fileStream = null; lock (_gate)
try
{ {
if (!File.Exists(filePath)) FileStream fileStream = null;
try
{ {
using (fileStream = File.Create(filePath)) if (!File.Exists(filePath))
{ {
fileStream.Close(); using (fileStream = File.Create(filePath))
return; {
fileStream.Close();
return;
}
} }
} }
} catch
catch {
{ string text = "FailedToCreateFile:" + filePath;
string text = "FailedToCreateFile:" + filePath; fileStream?.Dispose();
fileStream?.Dispose(); _failureWriteClientLog = _failureWriteClientLog + text + "\n";
_failureWriteClientLog = _failureWriteClientLog + text + "\n"; }
} }
} }
@@ -121,6 +134,14 @@ public class LocalLog
} }
private static void MakeTreceLogToSend(TRACELOG_TYPE logType, Action onSended) 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) if (string.IsNullOrEmpty(Certification.Udid) || Certification.ViewerId == 0)
{ {
@@ -244,28 +265,45 @@ public class LocalLog
public static void RecordResouseLoadError(int errorFlag) public static void RecordResouseLoadError(int errorFlag)
{ {
UIManager.ViewScene currentScene = UIManager.GetInstance().GetCurrentScene(); lock (_gate)
string text = ((currentScene == UIManager.ViewScene.Battle && ToolboxGame.RealTimeNetworkAgent != null) ? "NetworkBattle" : currentScene.ToString()); {
AccumulateTraceLog("ResourcesManager ParallelAssetListExec Error in " + text + " : " + errorFlag); 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() public static void RecordTurnEndIfLoadErrorOccured()
{ {
if (ExistResourceLoadErrorInNetWorkBattle()) lock (_gate)
{ {
AccumulateTraceLog("TurnEnd After LoadError"); if (ExistResourceLoadErrorInNetWorkBattleLocked())
{
AccumulateTraceLogLocked("TurnEnd After LoadError");
}
} }
} }
public static void RecordFreezeLogIfLoadErrorOccured() public static void RecordFreezeLogIfLoadErrorOccured()
{ {
if (ExistResourceLoadErrorInNetWorkBattle()) lock (_gate)
{ {
AccumulateTraceLog("ResourceLoadFreeze in NetworkBattleScene"); if (ExistResourceLoadErrorInNetWorkBattleLocked())
{
AccumulateTraceLogLocked("ResourceLoadFreeze in NetworkBattleScene");
}
} }
} }
public static bool ExistResourceLoadErrorInNetWorkBattle() public static bool ExistResourceLoadErrorInNetWorkBattle()
{
lock (_gate)
{
return ExistResourceLoadErrorInNetWorkBattleLocked();
}
}
private static bool ExistResourceLoadErrorInNetWorkBattleLocked()
{ {
return ReadLogFile(AccumulateLogPath).Contains("ResourcesManager ParallelAssetListExec Error in NetworkBattle"); return ReadLogFile(AccumulateLogPath).Contains("ResourcesManager ParallelAssetListExec Error in NetworkBattle");
} }
@@ -329,148 +367,206 @@ public class LocalLog
} }
public static void AccumulateTraceLog(string log) public static void AccumulateTraceLog(string log)
{
lock (_gate)
{
AccumulateTraceLogLocked(log);
}
}
private static void AccumulateTraceLogLocked(string log)
{ {
AccumulateLog(GetBattleAndViewIdText() + log, "TraceLog"); AccumulateLog(GetBattleAndViewIdText() + log, "TraceLog");
} }
public static void AccumulateTraceInquiryLog(string log) public static void AccumulateTraceInquiryLog(string log)
{ {
AccumulateLog(log, "TraceInquiryLog"); lock (_gate)
OrganizeInquiryLog(); {
AccumulateLog(log, "TraceInquiryLog");
OrganizeInquiryLog();
}
} }
public static void AccumulateSettingLog() public static void AccumulateSettingLog()
{ {
if (!(ToolboxGame.RealTimeNetworkAgent == null)) lock (_gate)
{ {
string battleAndViewIdText = GetBattleAndViewIdText(); if (!(ToolboxGame.RealTimeNetworkAgent == null))
string text = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.SHOW_BATTLE_EFFECT) ? "1" : "0"); {
string text2 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_TURN_END) ? "1" : "0"); string battleAndViewIdText = GetBattleAndViewIdText();
string text3 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_EVOLVE) ? "1" : "0"); string text = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.SHOW_BATTLE_EFFECT) ? "1" : "0");
string text4 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.IS_SELECT_WSS) ? "1" : "0"); string text2 = (PlayerPrefsWrapper.GetBool(PlayerPrefsWrapper.CONFIRM_TURN_END) ? "1" : "0");
AccumulateLog(battleAndViewIdText + "BattleSetting:" + text + text2 + text3 + text4, "TraceSettingLog"); 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) public static void AddGungnirLog(string log)
{ {
if (ToolboxGame.RealTimeNetworkAgent != null) lock (_gate)
{ {
DateTime dateTime = DateTime.Now.ToUniversalTime(); if (ToolboxGame.RealTimeNetworkAgent != null)
log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; {
_gungnirLog += log; 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() public static void InitGungnirLog()
{ {
_gungnirLog = ""; lock (_gate)
if (ToolboxGame.RealTimeNetworkAgent != null && _isSendGungnirLog)
{ {
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) public static void AddSocketFrameLog(string log)
{ {
if (ToolboxGame.RealTimeNetworkAgent != null) lock (_gate)
{ {
DateTime dateTime = DateTime.Now.ToUniversalTime(); if (ToolboxGame.RealTimeNetworkAgent != null)
log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; {
_socketFrameLog += log; 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() public static void InitSocketFrameLog()
{ {
_socketFrameLog = ""; lock (_gate)
if (ToolboxGame.RealTimeNetworkAgent != null && _isSendSocketFrameLog)
{ {
ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffSocketFrameLog"); _socketFrameLog = "";
if (ToolboxGame.RealTimeNetworkAgent != null && _isSendSocketFrameLog)
{
ToolboxGame.RealTimeNetworkAgent.NetworkLogger.LogInfo("OffSocketFrameLog");
}
_isSendSocketFrameLog = false;
} }
_isSendSocketFrameLog = false;
} }
public static void AccumulateTraceLogAddRoomCreateLog() public static void AccumulateTraceLogAddRoomCreateLog()
{ {
if (!_isRoomCreateLogEnd) lock (_gate)
{ {
_isRoomCreateLogEnd = true; if (!_isRoomCreateLogEnd)
AccumulateTraceLog("#696951 " + _roomCreateLog); {
InitRoomCreateLog(); _isRoomCreateLogEnd = true;
AccumulateTraceLogLocked("#696951 " + _roomCreateLog);
_roomCreateLog = "";
}
} }
} }
public static void AddRoomCreateLog(string log) public static void AddRoomCreateLog(string log)
{ {
if (!_isRoomCreateLogEnd) lock (_gate)
{ {
DateTime dateTime = DateTime.Now.ToUniversalTime(); if (!_isRoomCreateLogEnd)
log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + log + "\n"; {
_roomCreateLog += log; 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() public static void InitRoomCreateLog()
{ {
_roomCreateLog = ""; lock (_gate)
{
_roomCreateLog = "";
}
} }
public static void UpdateLoadResourceLog(string log) public static void UpdateLoadResourceLog(string log)
{ {
_loadResourceLog = log; lock (_gate)
{
_loadResourceLog = log;
}
} }
public static string GetLoadResourceLog() public static string GetLoadResourceLog()
{ {
return _loadResourceLog; lock (_gate)
{
return _loadResourceLog;
}
} }
public static void SetDisconnectLog(string log) public static void SetDisconnectLog(string log)
{ {
if (_disconnectLog == "") lock (_gate)
{ {
DateTime dateTime = DateTime.Now.ToUniversalTime(); if (_disconnectLog == "")
log = dateTime.Day + "/" + dateTime.Hour + ":" + dateTime.Minute + ":" + dateTime.Second + ":" + dateTime.Millisecond.ToString("000") + ":[" + ToolboxGame.RealTimeNetworkAgent.NowSocketID + "]" + log + "\n"; {
_disconnectLog = log; 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() public static void InitDisconnectLog()
{ {
_disconnectLog = ""; lock (_gate)
{
_disconnectLog = "";
}
} }
public static void AccumulateLastTraceLog(string log) public static void AccumulateLastTraceLog(string log)
{ {
if (_lastTraceLogStringBuilder == null) lock (_gate)
{ {
_lastTraceLogStringBuilder = new StringBuilder(); if (_lastTraceLogStringBuilder == null)
} {
else _lastTraceLogStringBuilder = new StringBuilder();
{ }
_lastTraceLogStringBuilder.Append("\n"); else
} {
float num = 0f; _lastTraceLogStringBuilder.Append("\n");
if (num != 0f && num / (float)SystemInfo.systemMemorySize > 0.8f) }
{ float num = 0f;
string text = ""; if (num != 0f && num / (float)SystemInfo.systemMemorySize > 0.8f)
log += text; {
} string text = "";
if (!_isLastTraceLogTimeAdd) log += text;
{ }
DateTime dateTime = DateTime.Now.ToUniversalTime(); if (!_isLastTraceLogTimeAdd)
_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; 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);
else _isLastTraceLogTimeAdd = true;
{ }
_lastTraceLogStringBuilder.Append(log); else
{
_lastTraceLogStringBuilder.Append(log);
}
} }
} }
public static void SubmitAccumulateLastTraceLog() public static void SubmitAccumulateLastTraceLog()
{
lock (_gate)
{
SubmitAccumulateLastTraceLogLocked();
}
}
private static void SubmitAccumulateLastTraceLogLocked()
{ {
if (_lastTraceLogStringBuilder != null) if (_lastTraceLogStringBuilder != null)
{ {
@@ -491,26 +587,29 @@ public class LocalLog
public static void SetLastTraceLogTurn(int turn) public static void SetLastTraceLogTurn(int turn)
{ {
if (currentTurn != turn) lock (_gate)
{ {
string log = "Turn" + turn + " " + GetBattleAndViewIdText() + "====\n"; if (currentTurn != turn)
string text = "";
if (turn % 2 == 0)
{ {
text = "LastTraceLog1"; string log = "Turn" + turn + " " + GetBattleAndViewIdText() + "====\n";
nowTraceTurn = 0; 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) private static void ClearLogByType(TRACELOG_TYPE logType)
{ {
switch (logType) lock (_gate)
{ {
case TRACELOG_TYPE.TRACE_ALL_LOG: switch (logType)
_failureWriteClientLog = ""; {
ClearTraceLog(); case TRACELOG_TYPE.TRACE_ALL_LOG:
ClearLastLogKey(); _failureWriteClientLog = "";
ClearInquiryLogKey(); ClearTraceLogLocked();
break; ClearLastLogKeyLocked();
case TRACELOG_TYPE.TRACE_LOG: ClearInquiryLogKey();
_failureWriteClientLog = ""; break;
ClearTraceLog(); case TRACELOG_TYPE.TRACE_LOG:
break; _failureWriteClientLog = "";
case TRACELOG_TYPE.TRACE_LAST_LOG: ClearTraceLogLocked();
ClearLastLogKey(); break;
break; case TRACELOG_TYPE.TRACE_LAST_LOG:
ClearLastLogKeyLocked();
break;
}
} }
} }
public static void ClearTraceLog() public static void ClearTraceLog()
{
lock (_gate)
{
ClearTraceLogLocked();
}
}
private static void ClearTraceLogLocked()
{ {
ClearLog("TraceLog"); ClearLog("TraceLog");
ClearLog("TraceSettingLog"); ClearLog("TraceSettingLog");
@@ -676,7 +786,10 @@ public class LocalLog
public static void ClearLastTraceLog(string key) public static void ClearLastTraceLog(string key)
{ {
ClearLog(key); lock (_gate)
{
ClearLog(key);
}
} }
private static void ClearInquiryLogKey() private static void ClearInquiryLogKey()
@@ -726,19 +839,38 @@ public class LocalLog
public static void ClearLastLogKey() public static void ClearLastLogKey()
{ {
ClearLastTraceLog("LastTraceLog1"); lock (_gate)
ClearLastTraceLog("LastTraceLog2"); {
ClearLastLogKeyLocked();
}
}
private static void ClearLastLogKeyLocked()
{
ClearLog("LastTraceLog1");
ClearLog("LastTraceLog2");
currentTurn = -1; currentTurn = -1;
} }
public static void ClearAllLog() public static void ClearAllLog()
{ {
ClearTraceLog(); lock (_gate)
ClearLastLogKey(); {
ClearInquiryLogKey(); ClearTraceLogLocked();
ClearLastLogKeyLocked();
ClearInquiryLogKey();
}
} }
public static void RecordCheckLog(RecordType type, bool isWin) 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) if (BattleManagerBase.GetIns() == null || !isWin)
{ {

View File

@@ -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 <Method>(<args>)
- {
- <body>
- }
+ public static void <Method>(<args>)
+ {
+ lock (_gate)
+ {
+ <body>
+ }
+ }

View File

@@ -54,13 +54,13 @@ namespace UnityEngine
// dictionary that the resolution-path ctor then Instantiate()s + GetComponent()s // 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 // (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<T> (null). // chain yields a non-null object. Typed asset loads go through the generic Load<T> (null).
private static readonly System.Collections.Generic.Dictionary<string, GameObject> _loaded // ConcurrentDictionary + GetOrAdd so concurrent battle setups don't race on first-miss.
= new System.Collections.Generic.Dictionary<string, GameObject>(); private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, GameObject> _loaded
= new System.Collections.Concurrent.ConcurrentDictionary<string, GameObject>();
public static Object Load(string path) public static Object Load(string path)
{ {
if (string.IsNullOrEmpty(path)) return null; if (string.IsNullOrEmpty(path)) return null;
if (!_loaded.TryGetValue(path, out var go)) { go = new GameObject(path); _loaded[path] = go; } return _loaded.GetOrAdd(path, static p => new GameObject(p));
return go;
} }
public static Object Load(string path, Type t) => Load(path); public static Object Load(string path, Type t) => Load(path);
public static T[] LoadAll<T>(string path) where T : Object => new T[0]; public static T[] LoadAll<T>(string path) where T : Object => new T[0];

View File

@@ -213,19 +213,27 @@ namespace UnityEngine
// off prefab GameObjects and use them unguarded (F1). Lazily create + cache a no-op instance // 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. // per concrete Component-derived type so those touches resolve harmlessly instead of NRE.
// Non-Component T or abstract/uninstantiable T still returns default (null). // Non-Component T or abstract/uninstantiable T still returns default (null).
private System.Collections.Generic.Dictionary<Type, object> _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<Type, object> _components;
private object GetOrAddComponent(Type t) private object GetOrAddComponent(Type t)
{ {
if (t == null || t.IsAbstract || !typeof(Component).IsAssignableFrom(t)) return null; if (t == null || t.IsAbstract || !typeof(Component).IsAssignableFrom(t)) return null;
_components ??= new System.Collections.Generic.Dictionary<Type, object>(); var map = _components;
if (_components.TryGetValue(t, out var c)) return c; if (map == null)
object inst; {
try { inst = Activator.CreateInstance(t); } var fresh = new System.Collections.Concurrent.ConcurrentDictionary<Type, object>();
catch { return null; } map = System.Threading.Interlocked.CompareExchange(ref _components, fresh, null) ?? fresh;
_components[t] = inst; }
if (inst is Component comp) comp._go = this; return map.GetOrAdd(t, ty =>
WireComponentFields(inst); {
return inst; 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 // The createNullView:false card-creation path reads many view-leaf reference fields off a
// CardTemplate component (UILabel/MeshRenderer/Transform/GameObject) UNGUARDED, plus the // CardTemplate component (UILabel/MeshRenderer/Transform/GameObject) UNGUARDED, plus the