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);