fix(tests): single shared bootstrap; idempotent SerializationThreadWorker.Exit (#2473)
## Problem Running the full `UOContent.Tests` suite, the test host **hangs ~2.5 minutes at shutdown and then crashes** (`Test host process crashed` / run aborted). The tests themselves are fine — they complete in ~1s — but the process can't exit. Captured via `--blame-hang` dump. The blocking thread: ``` System.Threading.WaitHandle.WaitOne() Server.SerializationThreadWorker.Sleep() SerializationThreadWorker.cs:54 (_stopEvent.WaitOne()) Server.SerializationThreadWorker.Exit() SerializationThreadWorker.cs:61 Server.World.ExitSerializationThreads() World.cs:429 Server.Tests.UOContentFixture..ctor() ``` ### Root cause Both collection fixtures (`UOContentFixture` and `PathfindingTestFixture`) each run the **full process-global ModernUO bootstrap**. `World.Load()` is guarded to run once per process, so the **second** fixture's `World.Load()` is a no-op and does **not** respawn the serialization workers — but `World.ExitSerializationThreads()` is **not** guarded, so the second fixture calls `Exit()` on workers whose threads have already terminated. `Exit()` → `Sleep()` → `_stopEvent.WaitOne()` then blocks forever (a dead thread never sets the event). The first collection's tests run; the second collection's fixture deadlocks in its constructor; the host eventually gets killed. This is why single-collection (filtered) runs were fine — only one fixture ever bootstraps — but the full suite hangs. It's not a parallelization race: even strictly sequential, the second fixture deadlocks. ## Fix **(a) Engine — idempotent `SerializationThreadWorker.Exit()`** A second `Exit()` is now a safe no-op instead of a permanent block. Only the owning (main) thread calls `Exit()`, so no synchronization is needed, and the single-call production shutdown path is unchanged. **(b) Tests — one shared bootstrap, strictly sequential collections** - New `TestServerBootstrap.EnsureInitialized()` runs the superset global init **exactly once per process** (lock + once-flag). - `UOContentFixture` / `PathfindingTestFixture` slim down to delegate to it and no longer tear down global state (which the single-bootstrap model owns for the host's lifetime). - `[assembly: CollectionBehavior(DisableTestParallelization = true)]` so collections never overlap. ## Result | | Before | After | |---|---|---| | Tests run (full suite) | 258 (UOContent collection deadlocked) | **418** | | Outcome | 2.5-min hang → host crash | **418 passed, clean exit** | | Wall time | killed | **~7s** |
This commit is contained in:
parent
1c1fd4d930
commit
412a71dfe0
6 changed files with 139 additions and 153 deletions
|
|
@ -1,63 +1,13 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using Server.Items;
|
||||
using Server.Misc;
|
||||
using Server.Tests.Maps;
|
||||
using Xunit;
|
||||
|
||||
namespace Server.Tests;
|
||||
|
||||
[CollectionDefinition("Sequential UOContent Tests", DisableParallelization = true)]
|
||||
public class UOContentFixture : ICollectionFixture<UOContentFixture>, IDisposable
|
||||
public class UOContentFixture : ICollectionFixture<UOContentFixture>
|
||||
{
|
||||
public UOContentFixture()
|
||||
{
|
||||
Core.ApplicationAssembly = Assembly.GetExecutingAssembly();
|
||||
Core.LoopContext = new EventLoopContext();
|
||||
Core.Expansion = Expansion.EJ;
|
||||
|
||||
// Load Configurations
|
||||
ServerConfiguration.Load(true);
|
||||
|
||||
// Load UOContent.dll into the type resolver
|
||||
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
|
||||
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
|
||||
|
||||
// Load Skills
|
||||
SkillsInfo.Configure();
|
||||
|
||||
// Configure networking (initializes RingSocketManager for tests)
|
||||
Server.Network.NetState.Configure();
|
||||
|
||||
// Configure / Initialize
|
||||
TestMapDefinitions.ConfigureTestMapDefinitions();
|
||||
|
||||
// Configure the world
|
||||
World.Configure();
|
||||
|
||||
Timer.Init(0);
|
||||
|
||||
// Configure Races
|
||||
RaceDefinitions.Configure();
|
||||
|
||||
// Load the world
|
||||
World.Load();
|
||||
|
||||
World.ExitSerializationThreads();
|
||||
|
||||
DecayScheduler.Configure();
|
||||
}
|
||||
|
||||
private static int _counter;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_counter++;
|
||||
|
||||
if (_counter > 1)
|
||||
{
|
||||
throw new Exception("NO!");
|
||||
}
|
||||
Timer.Init(0);
|
||||
}
|
||||
// All process-global initialization lives in TestServerInitializer and runs exactly once,
|
||||
// shared across every collection fixture. Tearing down global state here is intentionally
|
||||
// omitted: the world/serialization workers are initialized once and reused for the whole
|
||||
// test host, so there is nothing per-collection to dispose.
|
||||
public UOContentFixture() => TestServerInitializer.Initialize();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue