ModernUO/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs
Kamron Batman 412a71dfe0
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** |
2026-06-07 12:36:37 -07:00

115 lines
4.2 KiB
C#

using System;
using System.IO;
using System.Reflection;
using System.Threading;
using Server.Items;
using Server.Misc;
using Server.Movement;
using Server.Tests.Maps;
namespace Server.Tests;
/// <summary>
/// Single, process-wide ModernUO bootstrap for the UOContent test host. Mirrors Server.Tests'
/// TestServerInitializer in name and shape; kept as a separate (non-shared) copy because this
/// one loads the UOContent assembly and configures the UOContent-specific systems. Both types
/// are <c>internal</c> so the shared name stays scoped to each assembly.
///
/// ModernUO bootstraps its global singletons (Core, ServerConfiguration, AssemblyHandler,
/// NetState/io-ring, World, Timer, the serialization workers, and TileData) exactly once per
/// process. <see cref="World.Load"/> is guarded to run once, and
/// <see cref="World.ExitSerializationThreads"/> must run once against the live workers. Each
/// xUnit collection gets its own fixture instance, so this guard makes the bootstrap run a
/// single time regardless of how many collection fixtures are constructed. The two stateful
/// collections use <c>[CollectionDefinition(DisableParallelization = true)]</c> so they never
/// overlap; pure tests still run in parallel.
/// </summary>
internal static class TestServerInitializer
{
private static bool _initialized;
private static readonly Lock _lock = new();
public static void Initialize()
{
lock (_lock)
{
if (_initialized)
{
return;
}
Core.ApplicationAssembly = Assembly.GetExecutingAssembly();
Core.LoopContext = new EventLoopContext();
Core.Expansion = Expansion.EJ;
ServerConfiguration.Load(true);
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
// Required for the pathfinding tests (real .mul tile data). Harmless for the rest.
var clientFiles = Environment.GetEnvironmentVariable("MODERNUO_TEST_DATA_DIR")
?? @"C:\Ultima Online Classic";
ServerConfiguration.DataDirectories.Add(clientFiles);
AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]);
SkillsInfo.Configure();
Server.Network.NetState.Configure();
TestMapDefinitions.ConfigureTestMapDefinitions();
World.Configure();
Timer.Init(0);
RaceDefinitions.Configure();
MovementImpl.Configure();
PathFollower.Configure();
World.Load();
World.ExitSerializationThreads();
DecayScheduler.Configure();
// TileData's static cctor short-circuits when running under xUnit
// (see Server/TileData.cs:295). Force-load via reflection so LandTable/ItemTable
// flags are populated; without this, every tile reads as flag=None and
// MovementImpl.CheckMovement treats everything as walkable.
ForceLoadTileData();
VerifyTrammelTileDataLoaded();
_initialized = true;
}
}
private static void ForceLoadTileData()
{
var loadMethod = typeof(TileData).GetMethod(
"Load",
BindingFlags.Static | BindingFlags.NonPublic
);
if (loadMethod == null)
{
throw new InvalidOperationException(
"TileData.Load not found via reflection — engine may have refactored."
);
}
loadMethod.Invoke(null, null);
}
private static void VerifyTrammelTileDataLoaded()
{
var trammel = Map.Maps[1];
if (trammel == null)
{
throw new InvalidOperationException(
"Trammel (mapId=1) was not registered. Check TestMapDefinitions."
);
}
var tile = trammel.Tiles.GetLandTile(1500, 1600);
if (tile.ID == 0)
{
throw new InvalidOperationException(
$"Trammel tile data did not load — GetLandTile(1500,1600) returned ID 0. " +
$"Verify Distribution/Data/map1*.mul (or map1LegacyMUL.uop) is present at " +
$"{Path.Combine(Core.BaseDirectory, "Data")}."
);
}
}
}