feat(A.5 T11): activate LandblockStreamer worker thread
Phase A.1 reverted to synchronous mode due to DatCollection thread- safety; T10 documented the lock that makes concurrent reads safe. T11 activates the dedicated worker thread and switches enqueue methods to non-blocking Channel.Writer.TryWrite. EnqueueLoad now takes LandblockStreamJobKind (default: LoadNear from all callers, matching previous full-load semantics). T13/T16 will route by kind per TwoTierDiff. Constructor gains optional buildMeshOrNull param (defaults to null- returning stub); T12 wires the real LandblockMesh.Build factory. GameWindow construction site updated: Action<uint> enqueueLoad delegate now wraps a lambda (method group won't bind to Action<uint> when the method has an optional second param). LandblockStreamerTests updated: the synchronous-thread-pinning test replaced by Load_ExecutesLoaderOnWorkerThread which asserts the loader runs on a different thread; Load_FollowedByDrain now supplies a stubMesh so the worker can produce Loaded (not Failed) results. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0cf86bb126
commit
00bb030c9f
2 changed files with 102 additions and 69 deletions
|
|
@ -8,28 +8,27 @@ using AcDream.Core.World;
|
|||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Services landblock load/unload requests by invoking a caller-supplied
|
||||
/// load delegate (the production instance wraps
|
||||
/// <see cref="LandblockLoader.Load"/>) and posting results to an outbox
|
||||
/// the render thread drains once per OnUpdate.
|
||||
/// Services landblock load/unload requests by invoking caller-supplied
|
||||
/// factory delegates (the production instance wraps
|
||||
/// <see cref="LandblockLoader.Load"/> for loading and
|
||||
/// <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/> for the terrain
|
||||
/// mesh) and posting results to an outbox the render thread drains once
|
||||
/// per OnUpdate.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Currently runs synchronously on the calling thread.</b> The original
|
||||
/// Phase A.1 design ran loads on a dedicated worker thread, but DatReaderWriter's
|
||||
/// <c>DatCollection</c> is not thread-safe — concurrent reads from a worker
|
||||
/// and the render thread (animation tick, live spawn handlers) corrupt
|
||||
/// internal buffer state and produce half-populated <c>LandBlock.Height[]</c>
|
||||
/// arrays which render as wildly distorted terrain. Until Phase A.3 introduces
|
||||
/// a thread-safe dat wrapper, loads are synchronous: <see cref="EnqueueLoad"/>
|
||||
/// invokes the load delegate inline and writes the result to the outbox in
|
||||
/// a single call. This causes a frame hitch when crossing landblock
|
||||
/// boundaries, but the rendering is correct.
|
||||
/// <b>Thread model (Phase A.5 T11+):</b> <see cref="Start"/> spawns a
|
||||
/// dedicated background worker thread. <see cref="EnqueueLoad"/> and
|
||||
/// <see cref="EnqueueUnload"/> write non-blocking to the inbox
|
||||
/// <see cref="Channel{T}"/>; the worker drains it and posts
|
||||
/// <see cref="LandblockStreamResult"/> records to the outbox.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The Channel-based outbox + <see cref="DrainCompletions"/> API is
|
||||
/// preserved so the move back to async loading is a single-class change
|
||||
/// when DatCollection thread safety lands.
|
||||
/// <b>DatCollection thread safety</b> is provided by the caller:
|
||||
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
|
||||
/// <c>DatCollection.Get<T></c> calls. Both factory closures passed at
|
||||
/// construction acquire that lock before reading dats. The worker never
|
||||
/// touches <c>DatCollection</c> directly — it only calls the factories.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -39,8 +38,9 @@ namespace AcDream.App.Streaming;
|
|||
/// </para>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Threading: synchronous mode means all methods must be called from the
|
||||
/// same thread (the render thread in production).
|
||||
/// Threading: <see cref="DrainCompletions"/> must be called from a single
|
||||
/// consumer thread (the render thread in production). All other public
|
||||
/// methods are thread-safe.
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
public sealed class LandblockStreamer : IDisposable
|
||||
|
|
@ -53,49 +53,65 @@ public sealed class LandblockStreamer : IDisposable
|
|||
public const int DefaultDrainBatchSize = 4;
|
||||
|
||||
private readonly Func<uint, LoadedLandblock?> _loadLandblock;
|
||||
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
|
||||
private readonly Channel<LandblockStreamJob> _inbox;
|
||||
private readonly Channel<LandblockStreamResult> _outbox;
|
||||
private readonly CancellationTokenSource _cancel = new();
|
||||
#pragma warning disable CS0649 // _worker stays declared for the future async path; unused in synchronous mode.
|
||||
private Thread? _worker;
|
||||
#pragma warning restore CS0649
|
||||
private int _disposed;
|
||||
|
||||
public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock)
|
||||
public LandblockStreamer(
|
||||
Func<uint, LoadedLandblock?> loadLandblock,
|
||||
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
|
||||
{
|
||||
_loadLandblock = loadLandblock;
|
||||
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
|
||||
// Default: no mesh build (returns null → Failed result). Production
|
||||
// wires in LandblockMesh.Build via the T12 construction site.
|
||||
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
|
||||
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op in synchronous mode. Preserved on the API surface so callers
|
||||
/// don't need to change when async loading returns in Phase A.3.
|
||||
/// Activate the dedicated background worker thread. Idempotent: calling
|
||||
/// <see cref="Start"/> more than once has no effect.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
// No worker thread in synchronous mode.
|
||||
if (_worker != null) return;
|
||||
_worker = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "acdream.streaming.worker",
|
||||
};
|
||||
_worker.Start();
|
||||
}
|
||||
|
||||
public void EnqueueLoad(uint landblockId)
|
||||
/// <summary>
|
||||
/// Non-blocking enqueue. The worker drains the inbox and posts a
|
||||
/// <see cref="LandblockStreamResult.Loaded"/> (or
|
||||
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
|
||||
/// </summary>
|
||||
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
// Synchronous mode: invoke the load delegate inline. The result lands
|
||||
// in the outbox and DrainCompletions picks it up later in the same
|
||||
// (or next) frame.
|
||||
HandleJob(new LandblockStreamJob.Load(landblockId, LandblockStreamJobKind.LoadNear));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking enqueue. The worker posts a
|
||||
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
|
||||
/// </summary>
|
||||
public void EnqueueUnload(uint landblockId)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
HandleJob(new LandblockStreamJob.Unload(landblockId));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -118,17 +134,14 @@ public sealed class LandblockStreamer : IDisposable
|
|||
{
|
||||
try
|
||||
{
|
||||
// Synchronous read loop via .WaitToReadAsync + ReadAllAsync
|
||||
// would be idiomatic but requires async; the blocking reader
|
||||
// is simpler and the thread is dedicated anyway.
|
||||
// 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.
|
||||
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())
|
||||
break;
|
||||
|
||||
|
|
@ -157,7 +170,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
switch (job)
|
||||
{
|
||||
case LandblockStreamJob.Load load:
|
||||
// TODO(A.5 T11/T16): route by load.Kind. LoadFar will skip
|
||||
// TODO(A.5 T16): route by load.Kind. LoadFar will skip
|
||||
// LandBlockInfo + scenery generation; PromoteToNear will skip
|
||||
// mesh build (terrain already on GPU). Today every Kind takes
|
||||
// the full-load path via _loadLandblock, which matches today's
|
||||
|
|
@ -166,15 +179,22 @@ public sealed class LandblockStreamer : IDisposable
|
|||
{
|
||||
var lb = _loadLandblock(load.LandblockId);
|
||||
if (lb is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "LandblockLoader.Load returned null"));
|
||||
else
|
||||
// TEMPORARY: passes default! for MeshData — Task 13 wires the real mesh build.
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId,
|
||||
LandblockStreamTier.Near,
|
||||
lb,
|
||||
MeshData: default! /* TODO(A.5 T13) */));
|
||||
break;
|
||||
}
|
||||
var mesh = _buildMeshOrNull(load.LandblockId, lb);
|
||||
if (mesh is null)
|
||||
{
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
|
||||
load.LandblockId, "buildMeshOrNull returned null"));
|
||||
break;
|
||||
}
|
||||
var tier = load.Kind == LandblockStreamJobKind.LoadFar
|
||||
? LandblockStreamTier.Far : LandblockStreamTier.Near;
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
|
||||
load.LandblockId, tier, lb, mesh));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue