diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs
index 6b08095..a3416de 100644
--- a/src/AcDream.App/Streaming/LandblockStreamer.cs
+++ b/src/AcDream.App/Streaming/LandblockStreamer.cs
@@ -75,20 +75,27 @@ public sealed class LandblockStreamer : IDisposable
}
///
- /// Activate the dedicated background worker thread. Idempotent: calling
- /// more than once has no effect.
+ /// Activate the dedicated background worker thread. Idempotent and
+ /// thread-safe: concurrent callers will only spawn one worker; subsequent
+ /// calls are no-ops. Atomic via .
///
public void Start()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
- if (_worker != null) return;
- _worker = new Thread(WorkerLoop)
+
+ // A.5 T10-T12 follow-up: atomically install the worker so concurrent
+ // Start() callers don't both pass the null check and spawn duplicate
+ // threads. Construct the candidate; CAS it into _worker; if we lost
+ // the race, the candidate goes unstarted and is GCed.
+ var candidate = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
- _worker.Start();
+ if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
+ candidate.Start();
+ // else: another caller won the race; their thread is running.
}
///
diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
index 2e11804..7c5291c 100644
--- a/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
@@ -66,6 +66,39 @@ public class LandblockStreamerTests
Assert.IsType(result);
}
+ [Fact]
+ public async Task Load_WhenBuildMeshReturnsNull_ReportsFailed()
+ {
+ // Phase A.5 T10-T12 follow-up: the mesh-build factory may return
+ // null (e.g., LandBlock dat missing or corrupt). The worker must
+ // emit Failed in that case instead of constructing Loaded with a
+ // null MeshData (which would NRE downstream).
+ var stubLandblock = new LoadedLandblock(
+ 0xABCDFFFEu,
+ new LandBlock(),
+ System.Array.Empty());
+
+ using var streamer = new LandblockStreamer(
+ loadLandblock: _ => stubLandblock,
+ buildMeshOrNull: (_, _) => null); // mesh-build returns null
+
+ streamer.Start();
+ streamer.EnqueueLoad(0xABCDFFFEu);
+
+ LandblockStreamResult? result = null;
+ for (int i = 0; i < SpinMaxIterations && result is null; i++)
+ {
+ var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
+ if (drained.Count > 0) result = drained[0];
+ else await Task.Delay(SpinStepMs);
+ }
+
+ Assert.NotNull(result);
+ var failed = Assert.IsType(result);
+ Assert.Equal(0xABCDFFFEu, failed.LandblockId);
+ Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase);
+ }
+
[Fact]
public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage()
{
diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
index bafe59a..cb79116 100644
--- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
+++ b/tests/AcDream.Core.Tests/Streaming/StreamingControllerTests.cs
@@ -78,7 +78,13 @@ public class StreamingControllerTests
// Entities (positional record). Adjust if the first positional arg
// name differs.
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty());
- fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, MeshData: default!));
+ // A.5 T10-T12 follow-up: use a real empty mesh instance instead of
+ // default! so any future test that flows MeshData through the apply
+ // callback gets a non-null reference to inspect rather than an NRE.
+ var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
+ System.Array.Empty(),
+ System.Array.Empty());
+ fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh));
controller.Tick(50, 50);