fix(app): Phase A.1 — LandblockStreamer lifecycle + threading hardening

Code review follow-up to commit 0904372. Five Important fixes plus
three Minor polish items found by the reviewer before StreamingController
depends on this class under churn.

I1: Dispose is now thread-safe via Interlocked.Exchange on an int
    guard. Two concurrent Dispose calls no longer double-dispose the
    CancellationTokenSource.

I2: EnqueueLoad/EnqueueUnload now throw ObjectDisposedException when
    called after Dispose instead of silently dropping the job. Jobs
    vanishing into a completed channel was a debugging hazard.

I3: Start throws ObjectDisposedException when called after Dispose
    instead of silently doing nothing (the old guard only checked
    whether the thread was non-null, not whether the streamer was
    still usable).

I4: New test Load_ExecutesLoaderOnBackgroundThread captures the
    loader delegate's ManagedThreadId and asserts it differs from
    the test thread's id, proving the whole reason this class
    exists (off-thread execution) is actually happening.

I5: New LandblockStreamResult.WorkerCrashed record type for the
    outer catch in WorkerLoop. Previously the crash path wrote
    Failed(0, ex.ToString()) which collided with landblock (0, 0)
    in the north ocean, making "worker crashed" indistinguishable
    from "landblock 0 failed to load".

Minor polish:
- M1: Test spin constants (SpinTimeoutMs, SpinStepMs,
  SpinMaxIterations) extracted so the 200 x 10ms pattern has one
  source of truth.
- M2: DefaultDrainBatchSize public const on LandblockStreamer so
  the batch cap has a name and a comment explaining why 4.
- M3: Safety-argument comment on the sync-over-async
  WaitToReadAsync call explaining why it cannot deadlock (dedicated
  thread, no SyncContext).
- M6: XML remarks on the class and on DrainCompletions documenting
  threading contract (Enqueue = any thread, Drain = single consumer
  thread).

112 Core + 96 Core.Net tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:20:41 +02:00
parent 0904372af6
commit c5e207a51f
3 changed files with 88 additions and 17 deletions

View file

@ -25,4 +25,13 @@ public abstract record LandblockStreamResult(uint LandblockId)
public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId); public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId);
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId); public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId); public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);
/// <summary>
/// The worker loop itself crashed with an unhandled exception. Not tied
/// to a specific landblock — distinguished from <see cref="Failed"/>
/// because consumers typically route this to a fatal-log path rather
/// than retrying a single landblock later. LandblockId is 0 by
/// convention; readers should pattern-match on the type, not the id.
/// </summary>
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0);
} }

View file

@ -19,15 +19,28 @@ namespace AcDream.App.Streaming;
/// record so the render thread can release GPU state on the next drain — /// record so the render thread can release GPU state on the next drain —
/// the worker never touches GPU resources directly. /// the worker never touches GPU resources directly.
/// </para> /// </para>
///
/// <remarks>
/// Threading: <see cref="EnqueueLoad"/> / <see cref="EnqueueUnload"/> may
/// be called from any thread; <see cref="DrainCompletions"/> must be called
/// from a single consumer thread (the render thread in production).
/// </remarks>
/// </summary> /// </summary>
public sealed class LandblockStreamer : IDisposable public sealed class LandblockStreamer : IDisposable
{ {
/// <summary>
/// Default drain batch size. Tuned to cap GPU upload work the render
/// thread does per frame while still draining a moderate backlog in a
/// few frames. Callers can override on a per-call basis.
/// </summary>
public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LoadedLandblock?> _loadLandblock; private readonly Func<uint, LoadedLandblock?> _loadLandblock;
private readonly Channel<LandblockStreamJob> _inbox; private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox; private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new(); private readonly CancellationTokenSource _cancel = new();
private Thread? _worker; private Thread? _worker;
private bool _disposed; private int _disposed;
public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock) public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock)
{ {
@ -44,6 +57,8 @@ public sealed class LandblockStreamer : IDisposable
/// </summary> /// </summary>
public void Start() public void Start()
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
if (_worker is not null) return; if (_worker is not null) return;
_worker = new Thread(WorkerLoop) _worker = new Thread(WorkerLoop)
{ {
@ -55,11 +70,15 @@ public sealed class LandblockStreamer : IDisposable
public void EnqueueLoad(uint landblockId) public void EnqueueLoad(uint landblockId)
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId)); _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId));
} }
public void EnqueueUnload(uint landblockId) public void EnqueueUnload(uint landblockId)
{ {
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
} }
@ -67,7 +86,11 @@ public sealed class LandblockStreamer : IDisposable
/// Drain up to <paramref name="maxBatchSize"/> completed results. /// Drain up to <paramref name="maxBatchSize"/> completed results.
/// Non-blocking. Call from the render thread once per OnUpdate. /// Non-blocking. Call from the render thread once per OnUpdate.
/// </summary> /// </summary>
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = 4) /// <remarks>
/// Must be called from a single consumer thread. The outbox channel is
/// configured with SingleReader = true and will throw on concurrent reads.
/// </remarks>
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int maxBatchSize = DefaultDrainBatchSize)
{ {
var batch = new List<LandblockStreamResult>(maxBatchSize); var batch = new List<LandblockStreamResult>(maxBatchSize);
while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result)) while (batch.Count < maxBatchSize && _outbox.Reader.TryRead(out var result))
@ -84,6 +107,12 @@ public sealed class LandblockStreamer : IDisposable
// is simpler and the thread is dedicated anyway. // is simpler and the thread is dedicated anyway.
while (!_cancel.Token.IsCancellationRequested) while (!_cancel.Token.IsCancellationRequested)
{ {
// Safe to block: this is a dedicated worker thread with no
// SynchronizationContext, so .Result/.GetResult cannot deadlock
// against any captured continuation. Using the sync pattern
// here keeps the loop linear; an async-enumerable alternative
// would force WorkerLoop to be async Task and lose the
// simple thread-start shape.
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult()) if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
break; break;
@ -99,7 +128,7 @@ public sealed class LandblockStreamer : IDisposable
{ {
// Last-ditch: surface via outbox so the caller at least sees // Last-ditch: surface via outbox so the caller at least sees
// something. We never retry a crashed worker. // something. We never retry a crashed worker.
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(0, ex.ToString())); _outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
} }
finally finally
{ {
@ -137,8 +166,7 @@ public sealed class LandblockStreamer : IDisposable
public void Dispose() public void Dispose()
{ {
if (_disposed) return; if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
_disposed = true;
_cancel.Cancel(); _cancel.Cancel();
_inbox.Writer.TryComplete(); _inbox.Writer.TryComplete();
_worker?.Join(TimeSpan.FromSeconds(2)); _worker?.Join(TimeSpan.FromSeconds(2));

View file

@ -8,6 +8,10 @@ namespace AcDream.Core.Tests.Streaming;
public class LandblockStreamerTests public class LandblockStreamerTests
{ {
private const int SpinTimeoutMs = 2000;
private const int SpinStepMs = 10;
private const int SpinMaxIterations = SpinTimeoutMs / SpinStepMs;
[Fact] [Fact]
public async Task Load_FollowedByDrain_ReturnsLoadedRecord() public async Task Load_FollowedByDrain_ReturnsLoadedRecord()
{ {
@ -24,11 +28,11 @@ public class LandblockStreamerTests
// Spin until the worker produces a completion, with a 2s timeout. // Spin until the worker produces a completion, with a 2s timeout.
LandblockStreamResult? result = null; LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++) for (int i = 0; i < SpinMaxIterations && result is null; i++)
{ {
var drained = streamer.DrainCompletions(maxBatchSize: 4); var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0]; if (drained.Count > 0) result = drained[0];
else await Task.Delay(10); else await Task.Delay(SpinStepMs);
} }
Assert.NotNull(result); Assert.NotNull(result);
@ -47,11 +51,11 @@ public class LandblockStreamerTests
streamer.EnqueueLoad(0x12340000u); streamer.EnqueueLoad(0x12340000u);
LandblockStreamResult? result = null; LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++) for (int i = 0; i < SpinMaxIterations && result is null; i++)
{ {
var drained = streamer.DrainCompletions(4); var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0]; if (drained.Count > 0) result = drained[0];
else await Task.Delay(10); else await Task.Delay(SpinStepMs);
} }
Assert.NotNull(result); Assert.NotNull(result);
@ -68,11 +72,11 @@ public class LandblockStreamerTests
streamer.EnqueueLoad(0x55550000u); streamer.EnqueueLoad(0x55550000u);
LandblockStreamResult? result = null; LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++) for (int i = 0; i < SpinMaxIterations && result is null; i++)
{ {
var drained = streamer.DrainCompletions(4); var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0]; if (drained.Count > 0) result = drained[0];
else await Task.Delay(10); else await Task.Delay(SpinStepMs);
} }
var failed = Assert.IsType<LandblockStreamResult.Failed>(result); var failed = Assert.IsType<LandblockStreamResult.Failed>(result);
@ -88,14 +92,44 @@ public class LandblockStreamerTests
streamer.EnqueueUnload(0xABCD0000u); streamer.EnqueueUnload(0xABCD0000u);
LandblockStreamResult? result = null; LandblockStreamResult? result = null;
for (int i = 0; i < 200 && result is null; i++) for (int i = 0; i < SpinMaxIterations && result is null; i++)
{ {
var drained = streamer.DrainCompletions(4); var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0]; if (drained.Count > 0) result = drained[0];
else await Task.Delay(10); else await Task.Delay(SpinStepMs);
} }
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result); var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
Assert.Equal(0xABCD0000u, unloaded.LandblockId); Assert.Equal(0xABCD0000u, unloaded.LandblockId);
} }
[Fact]
public async Task Load_ExecutesLoaderOnBackgroundThread()
{
int testThreadId = System.Environment.CurrentManagedThreadId;
int? loaderThreadId = null;
var stubLandblock = new LoadedLandblock(
0x77770FFEu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
using var streamer = new LandblockStreamer(loadLandblock: id =>
{
loaderThreadId = System.Environment.CurrentManagedThreadId;
return stubLandblock;
});
streamer.Start();
streamer.EnqueueLoad(0x77770FFEu);
// Drain until we see the completion.
for (int i = 0; i < SpinMaxIterations && loaderThreadId is null; i++)
{
streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (loaderThreadId is null) await Task.Delay(SpinStepMs);
}
Assert.NotNull(loaderThreadId);
Assert.NotEqual(testThreadId, loaderThreadId.Value);
}
} }