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:
@@ -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<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.
|
||||
// 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)]
|
||||
|
||||
@@ -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).</summary>
|
||||
[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);
|
||||
})));
|
||||
|
||||
Reference in New Issue
Block a user