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:
Kamron Batman 2026-06-07 12:36:37 -07:00 committed by GitHub
parent 1c1fd4d930
commit 412a71dfe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 139 additions and 153 deletions

View file

@ -1,4 +1,3 @@
using System;
using Xunit;
namespace Server.Tests;
@ -8,7 +7,7 @@ namespace Server.Tests;
/// Configure client path via MODERNUO_CLIENT_PATH environment variable or place files at C:\Ultima Online Classic.
/// </summary>
[CollectionDefinition("Sequential Server Tests", DisableParallelization = true)]
public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
public class ServerFixture : ICollectionFixture<ServerFixture>
{
/// <summary>
/// True if TileData was successfully loaded from client files.
@ -35,13 +34,8 @@ public class ServerFixture : ICollectionFixture<ServerFixture>, IDisposable
/// </summary>
public static ushort SurfaceImpassableTileId => TestServerInitializer.SurfaceImpassableTileId;
public ServerFixture()
{
TestServerInitializer.Initialize(loadTileData: true);
}
public void Dispose()
{
Timer.Init(0);
}
// Global init runs exactly once via the shared, guarded TestServerInitializer. The single
// bootstrap owns global state for the lifetime of the test host, so there is no
// per-collection teardown — matching UOContent.Tests' TestServerInitializer pattern.
public ServerFixture() => TestServerInitializer.Initialize(loadTileData: true);
}

View file

@ -10,7 +10,7 @@ namespace Server.Tests;
/// Shared server initialization logic for test fixtures.
/// Ensures the server is only initialized once across all test collections.
/// </summary>
public static class TestServerInitializer
internal static class TestServerInitializer
{
private const string DefaultDataDirectory = @"C:\Ultima Online Classic";
private static bool _initialized;