## 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** |
183 lines
5.3 KiB
C#
183 lines
5.3 KiB
C#
using System.IO;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using Server.Items;
|
|
using Server.Tests.Maps;
|
|
|
|
namespace Server.Tests;
|
|
|
|
/// <summary>
|
|
/// Shared server initialization logic for test fixtures.
|
|
/// Ensures the server is only initialized once across all test collections.
|
|
/// </summary>
|
|
internal static class TestServerInitializer
|
|
{
|
|
private const string DefaultDataDirectory = @"C:\Ultima Online Classic";
|
|
private static bool _initialized;
|
|
private static readonly Lock _lock = new();
|
|
|
|
/// <summary>
|
|
/// True if TileData was successfully loaded from client files.
|
|
/// </summary>
|
|
public static bool TileDataLoaded { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A tile ID known to have the Surface flag. 0 if not found.
|
|
/// </summary>
|
|
public static ushort SurfaceTileId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A tile ID known to have the Impassable flag. 0 if not found.
|
|
/// </summary>
|
|
public static ushort ImpassableTileId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A tile ID known to have the Wet flag (water). 0 if not found.
|
|
/// </summary>
|
|
public static ushort WetTileId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// A tile ID known to have both Surface and Impassable flags (tables, furniture). 0 if not found.
|
|
/// </summary>
|
|
public static ushort SurfaceImpassableTileId { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Initializes the test server. Safe to call multiple times - only initializes once.
|
|
/// </summary>
|
|
/// <param name="loadTileData">If true, attempts to load TileData from client files.</param>
|
|
public static void Initialize(bool loadTileData = false)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
if (_initialized)
|
|
{
|
|
// Already initialized, but check if we need to load TileData now
|
|
if (loadTileData && !TileDataLoaded && TileData.MaxItemValue == 0)
|
|
{
|
|
TryLoadTileData();
|
|
DetectTileIds();
|
|
}
|
|
return;
|
|
}
|
|
|
|
Core.ApplicationAssembly = Assembly.GetExecutingAssembly();
|
|
|
|
// Load Configurations
|
|
ServerConfiguration.Load(true);
|
|
|
|
// Try to load TileData if requested
|
|
if (loadTileData)
|
|
{
|
|
TryLoadTileData();
|
|
}
|
|
|
|
// Load an empty assembly list into the resolver
|
|
ServerConfiguration.AssemblyDirectories.Add(Core.BaseDirectory);
|
|
AssemblyHandler.LoadAssemblies(["Server.dll"]);
|
|
|
|
Core.LoopContext = new EventLoopContext();
|
|
Core.Expansion = Expansion.EJ;
|
|
|
|
// Configure networking (initializes RingSocketManager for tests)
|
|
Server.Network.NetState.Configure();
|
|
|
|
// Configure / Initialize
|
|
TestMapDefinitions.ConfigureTestMapDefinitions();
|
|
|
|
// Configure the world
|
|
World.Configure();
|
|
|
|
Timer.Init(0);
|
|
|
|
// Load the world
|
|
World.Load();
|
|
|
|
World.ExitSerializationThreads();
|
|
|
|
// Detect tile IDs if TileData was loaded
|
|
if (loadTileData)
|
|
{
|
|
DetectTileIds();
|
|
}
|
|
|
|
DecayScheduler.Configure();
|
|
|
|
_initialized = true;
|
|
}
|
|
}
|
|
|
|
private static void TryLoadTileData()
|
|
{
|
|
var dataDir = GetDataDirectory();
|
|
if (string.IsNullOrEmpty(dataDir) || !Directory.Exists(dataDir))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ServerConfiguration.DataDirectories.Add(dataDir);
|
|
|
|
var tileDataPath = Core.FindDataFile("tiledata.mul", false);
|
|
if (File.Exists(tileDataPath))
|
|
{
|
|
TileData.Load();
|
|
}
|
|
}
|
|
|
|
private static void DetectTileIds()
|
|
{
|
|
if (TileData.MaxItemValue == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SurfaceTileId = FindTileWithFlag(TileFlag.Surface);
|
|
ImpassableTileId = FindTileWithFlag(TileFlag.Impassable);
|
|
WetTileId = FindTileWithFlag(TileFlag.Wet);
|
|
SurfaceImpassableTileId = FindTileWithFlags(TileFlag.Surface | TileFlag.Impassable);
|
|
TileDataLoaded = SurfaceTileId > 0 && ImpassableTileId > 0 && WetTileId > 0;
|
|
}
|
|
|
|
private static ushort FindTileWithFlag(TileFlag flag)
|
|
{
|
|
for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++)
|
|
{
|
|
var data = TileData.ItemTable[i];
|
|
if ((data.Flags & flag) != 0)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private static ushort FindTileWithFlags(TileFlag flags)
|
|
{
|
|
for (ushort i = 1; i <= TileData.MaxItemValue && i < 0xFFFF; i++)
|
|
{
|
|
var data = TileData.ItemTable[i];
|
|
if ((data.Flags & flags) == flags)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private static string GetDataDirectory()
|
|
{
|
|
// Check environment variable first
|
|
var envDir = System.Environment.GetEnvironmentVariable("MODERNUO_CLIENT_PATH");
|
|
if (!string.IsNullOrEmpty(envDir) && Directory.Exists(envDir))
|
|
{
|
|
return envDir;
|
|
}
|
|
|
|
// Fall back to default directory
|
|
if (Directory.Exists(DefaultDataDirectory))
|
|
{
|
|
return DefaultDataDirectory;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|