Login publishes the 25x25 window at a flat 32 blocks/s (~27 s in the
tunnel). The reveal-timing probe A/B (695a27b4) showed the consumer
budget env ceilings change nothing, which was read as producer-limited:
one "acdream.streaming.worker" thread, ~31 ms/block. This replaces the
single worker with min(ProcessorCount-2, 8) workers, floor 1.
Design: striped/affinity dispatch. Each worker owns one unbounded lane
channel plus its own high/low priority queues; jobs route to
lane = ((id >> 16) * 2654435761) % N (the low word of a landblock id is
constant, so the id is mixed before reduction). Striping was chosen
over a shared queue + in-flight conflict tracker because it preserves
the per-landblock contract structurally rather than by bookkeeping:
every job for one id lives on one lane, so per-id enqueue order IS
execution and completion-arrival order, and the same-landblock
supersede rules (PromoteToNear removes queued LoadFar/Unload) keep
seeing every queued job for that id. Contract, point by point:
- Per-landblock ordering: same id -> same lane -> serial FIFO.
- ClearLoads: broadcast to every lane inside the same _inboxGate lock
that serializes enqueues, so any load enqueued before
ClearPendingLoads() returns sits ahead of its lane's ClearLoads copy
in that lane's FIFO and is dropped at read time, exactly like the
single-thread path. Already-dequeued builds still complete (now up
to one per worker instead of one total); StreamingController's
SweepCollapsed already unloads those uniformly.
- Priority: per-lane high/low split unchanged. Cross-lane, priority is
not globally ordered (a lane cannot run another lane's job), which
the contract permits; near-tier jobs hash-spread across lanes and
are preferred within each.
- Outbox: SingleWriter flipped to false; nothing assumed single-writer
(PublishResult already used TryWrite + an Interlocked backlog, and
the consumer's peek->read head-stability holds because only the
single reader ever moves the head). Cross-landblock arrival order
was verified arbitrary-tolerant before relying on it:
StreamingController.AdmitCompletions classifies each result
independently into per-priority FIFOs (generation staleness +
per-landblock retirement blocking); per-landblock arrival order is
preserved by striping.
- Crash surface: per-worker. The first real crash publishes
WorkerCrashed (prefixed "worker N:" in pools > 1), sets
_workerFailure, completes every lane, and cancels the pool (a crash
still ends all processing, as before); siblings that merely observe
the closed lanes (ChannelClosedException) exit quietly instead of
reporting spurious crashes; the outbox completes only when the LAST
worker exits so no in-flight completions are dropped.
- Disposal: joins every worker under the same _disposeGate; Start
stays idempotent and dispose-serialized.
Thread-safety audit of the production build closures
(SessionPlayerComposition), per shared object:
- DatCollection (every read in LandblockBuildFactory.BuildLocked:
LandblockLoader.Load, SceneryGenerator.Generate, SetupMesh.Flatten,
CellMesh.Build, GfxObjBounds.Get, GfxObjDegradeResolver): NOT
thread-safe; already serialized under the shared _datLock, which
BuildLocked holds for the whole read transaction. Unchanged; the
probe run measured hold 0-13 ms / wait <= 12 ms during the login
window, so the lock is not the new bottleneck and the build was NOT
serialized beyond it.
- PakPreparedAssetSource / PakReader (BuildPreparedCollisionClosure,
outside the lock): immutable TOC array + read-only
MemoryMappedViewAccessor random-access reads + ConcurrentDictionary
verdict caches - safe for N concurrent readers (Slice I3 design;
the headless SharedPreparedCollisionCache wrapper is fully
lock-protected).
- LandblockMesh.Build (outside the lock): pure math over the dat
record + the composition-time height table + the immutable
TerrainBlendingContext record; the shared SurfaceCache is a
ConcurrentDictionary and BuildSurface is deterministic, so its
lookup-or-build race is last-write-wins-benign (the code already
documented exactly this).
- PhysicsDiagnostics probe statics: read-only bools + thread-safe
Console writes.
MEASURED OUTCOME (gate 4): the timing acceptance did NOT pass, and per
the task contract that is reported, not tuned around. With 8 workers
on this 16-core machine all 625 builds complete in ~203 ms
(ACDREAM_PROBE_TELEPORT BUILD lines t=3475390..3475593) - the producer
is off the critical path - but loaded= still advances at exactly
+32/1000 ms and SUMMARY totalMs measured 27395 and 27503 across two
runs (baseline 26728). The 32/s pacer is in the consumer
admission/publication path and is not governed by the
StreamingWorkBudgetOptions env ceilings. #418 stays IN-PROGRESS on the
consumer side; see docs/ISSUES.md for the evidence chain.
Tests: per-landblock ordering under 4-worker contention, cross-lane
ClearLoads drop, per-lane near-before-far preference, pool-of-1 serial
equivalence, disposal joining every worker, lane-spread guard, and
worker-count validation (LandblockStreamerPoolTests). Two existing
tests asserted a GLOBAL cross-landblock execution order - a serial
implementation detail, not the contract - and now pin workerCount: 1
with justification comments (LoadNear_OvertakesQueuedFarLoads,
TwoQueuedLoads_RetainTheirDistinctOriginAndGeneration).
Gates: Release build 0 errors; App suite 5575 passed / 3 skipped
(5568 + 7 new); Runtime suite 1756/0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
423 lines
16 KiB
C#
423 lines
16 KiB
C#
using System.Threading.Tasks;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Streaming;
|
|
|
|
public class LandblockStreamerTests
|
|
{
|
|
private const int SpinTimeoutMs = 2000;
|
|
private const int SpinStepMs = 10;
|
|
private const int SpinMaxIterations = SpinTimeoutMs / SpinStepMs;
|
|
|
|
[Fact]
|
|
public async Task Load_FollowedByDrain_ReturnsLoadedRecord()
|
|
{
|
|
var stubLandblock = new LoadedLandblock(
|
|
0xA9B4FFFEu,
|
|
new LandBlock(),
|
|
System.Array.Empty<WorldEntity>());
|
|
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null,
|
|
buildMeshOrNull: (_, _) => stubMesh);
|
|
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(0xA9B4FFFEu, generation: 42);
|
|
|
|
// Spin until the worker produces a completion, with a 2s timeout.
|
|
LandblockStreamResult? result = null;
|
|
for (int i = 0; i < SpinMaxIterations && result is null; i++)
|
|
{
|
|
var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize);
|
|
if (drained.Count > 0) result = drained[0];
|
|
else await Task.Delay(SpinStepMs);
|
|
}
|
|
|
|
Assert.NotNull(result);
|
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
|
|
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
|
|
Assert.Equal(42ul, loaded.Generation);
|
|
Assert.Same(stubLandblock, loaded.Landblock);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadNear_OvertakesQueuedFarLoads()
|
|
{
|
|
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
|
|
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
|
|
// workerCount: 1 — this test asserts a GLOBAL execution order across
|
|
// four DIFFERENT landblock ids, which only the serial degenerate pool
|
|
// guarantees (#418). The pool contract orders jobs per landblock id
|
|
// and prefers Near-tier per lane; the cross-lane variant lives in
|
|
// AcDream.App.Tests LandblockStreamerPoolTests.
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: (id, kind) =>
|
|
{
|
|
callOrder.Add((id, kind));
|
|
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
|
},
|
|
buildMeshOrNull: (_, _) => stubMesh,
|
|
workerCount: 1);
|
|
|
|
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
|
|
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
|
|
streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadFar);
|
|
streamer.EnqueueLoad(0xDDDDFFFFu, LandblockStreamJobKind.LoadNear);
|
|
streamer.Start();
|
|
|
|
var result = await DrainFirstAsync(streamer);
|
|
|
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(result);
|
|
Assert.Equal(0xDDDDFFFFu, loaded.LandblockId);
|
|
Assert.Equal((0xDDDDFFFFu, LandblockStreamJobKind.LoadNear), callOrder[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PromoteToNear_ProducesPromotedWithMeshData()
|
|
{
|
|
int meshBuildCalls = 0;
|
|
var entity = new WorldEntity
|
|
{
|
|
Id = 7,
|
|
SourceGfxObjOrSetupId = 0,
|
|
Position = System.Numerics.Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
MeshRefs = System.Array.Empty<MeshRef>()
|
|
};
|
|
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: (id, kind) => new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
|
buildMeshOrNull: (_, _) =>
|
|
{
|
|
meshBuildCalls++;
|
|
return new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
});
|
|
|
|
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
|
|
streamer.Start();
|
|
|
|
var result = await DrainFirstAsync(streamer);
|
|
|
|
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
|
|
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
|
|
Assert.Same(entity, promoted.Entities[0]);
|
|
Assert.NotNull(promoted.MeshData);
|
|
Assert.Equal(1, meshBuildCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Load_CarriesTheExactCompletedCellTransactionToTheConsumer()
|
|
{
|
|
var landblock = new LoadedLandblock(
|
|
0x8C04FFFFu,
|
|
new LandBlock(),
|
|
System.Array.Empty<WorldEntity>());
|
|
var cellBuild = new EnvCellLandblockBuild(
|
|
landblock.LandblockId,
|
|
System.Array.Empty<AcDream.App.Rendering.LoadedCell>(),
|
|
System.Array.Empty<EnvCellShellPlacement>());
|
|
var build = new LandblockBuild(landblock, cellBuild);
|
|
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: (_, _) => build,
|
|
buildMeshOrNull: (_, _) => mesh);
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(landblock.LandblockId, LandblockStreamJobKind.LoadNear);
|
|
|
|
var loaded = Assert.IsType<LandblockStreamResult.Loaded>(
|
|
await DrainFirstAsync(streamer));
|
|
|
|
Assert.Same(build, loaded.Build);
|
|
Assert.Same(cellBuild, loaded.Build.EnvCells);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock()
|
|
{
|
|
var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>();
|
|
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: (id, kind) =>
|
|
{
|
|
callOrder.Add((id, kind));
|
|
return new LoadedLandblock(id, new LandBlock(), System.Array.Empty<WorldEntity>());
|
|
},
|
|
buildMeshOrNull: (_, _) => stubMesh);
|
|
|
|
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.LoadFar);
|
|
streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear);
|
|
streamer.Start();
|
|
|
|
var result = await DrainFirstAsync(streamer);
|
|
|
|
var promoted = Assert.IsType<LandblockStreamResult.Promoted>(result);
|
|
Assert.Equal(0xA9B4FFFFu, promoted.LandblockId);
|
|
Assert.Equal((0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear), callOrder[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Load_WhenLoaderReturnsNull_ReportsFailed()
|
|
{
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: _ => null);
|
|
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(0x12340000u);
|
|
|
|
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);
|
|
Assert.IsType<LandblockStreamResult.Failed>(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<WorldEntity>());
|
|
|
|
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<LandblockStreamResult.Failed>(result);
|
|
Assert.Equal(0xABCDFFFEu, failed.LandblockId);
|
|
Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage()
|
|
{
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: _ => throw new System.InvalidOperationException("boom"));
|
|
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(0x55550000u);
|
|
|
|
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);
|
|
}
|
|
|
|
var failed = Assert.IsType<LandblockStreamResult.Failed>(result);
|
|
Assert.Contains("boom", failed.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unload_ProducesUnloadedResult()
|
|
{
|
|
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
|
|
|
streamer.Start();
|
|
streamer.EnqueueUnload(0xABCD0000u, generation: 43);
|
|
|
|
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);
|
|
}
|
|
|
|
var unloaded = Assert.IsType<LandblockStreamResult.Unloaded>(result);
|
|
Assert.Equal(0xABCD0000u, unloaded.LandblockId);
|
|
Assert.Equal(43ul, unloaded.Generation);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletionSourcePeekPreservesExactResultAndBacklog()
|
|
{
|
|
using var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
|
streamer.Start();
|
|
streamer.EnqueueUnload(0xABCE0000u, generation: 44);
|
|
|
|
for (int i = 0;
|
|
i < SpinMaxIterations && streamer.BacklogCount == 0;
|
|
i++)
|
|
{
|
|
await Task.Delay(SpinStepMs);
|
|
}
|
|
|
|
Assert.Equal(1, streamer.BacklogCount);
|
|
Assert.True(streamer.TryPeek(out LandblockStreamResult? firstPeek));
|
|
Assert.NotNull(firstPeek);
|
|
Assert.Equal(1, streamer.BacklogCount);
|
|
Assert.True(streamer.TryPeek(out LandblockStreamResult? secondPeek));
|
|
Assert.Same(firstPeek, secondPeek);
|
|
Assert.Equal(1, streamer.BacklogCount);
|
|
|
|
Assert.True(streamer.TryRead(out LandblockStreamResult? consumed));
|
|
Assert.Same(firstPeek, consumed);
|
|
Assert.Equal(0, streamer.BacklogCount);
|
|
Assert.False(streamer.TryRead(out _));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Load_ExecutesLoaderOnWorkerThread()
|
|
{
|
|
// Phase A.5 T11: the load delegate now runs on the dedicated worker
|
|
// thread (not the calling/render thread). This test verifies the
|
|
// async hand-off: EnqueueLoad returns immediately and the result
|
|
// appears in the outbox only after the worker processes the inbox.
|
|
int testThreadId = System.Environment.CurrentManagedThreadId;
|
|
int? loaderThreadId = null;
|
|
var stubLandblock = new LoadedLandblock(
|
|
0x77770FFEu,
|
|
new LandBlock(),
|
|
System.Array.Empty<WorldEntity>());
|
|
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
|
|
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
|
System.Array.Empty<uint>());
|
|
|
|
using var streamer = new LandblockStreamer(
|
|
loadLandblock: id =>
|
|
{
|
|
loaderThreadId = System.Environment.CurrentManagedThreadId;
|
|
return stubLandblock;
|
|
},
|
|
buildMeshOrNull: (_, _) => stubMesh);
|
|
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(0x77770FFEu);
|
|
|
|
// Spin until the worker produces a completion.
|
|
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);
|
|
Assert.IsType<LandblockStreamResult.Loaded>(result);
|
|
// The loader MUST have run on a different thread than the test thread.
|
|
Assert.NotNull(loaderThreadId);
|
|
Assert.NotEqual(testThreadId, loaderThreadId.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DisposeAndConcurrentDisposeWaitForInFlightLoad()
|
|
{
|
|
using var entered = new ManualResetEventSlim();
|
|
using var release = new ManualResetEventSlim();
|
|
var streamer = new LandblockStreamer(loadLandblock: _ =>
|
|
{
|
|
entered.Set();
|
|
release.Wait();
|
|
return null;
|
|
});
|
|
|
|
try
|
|
{
|
|
streamer.Start();
|
|
streamer.EnqueueLoad(0x12340000u);
|
|
Assert.True(entered.Wait(TimeSpan.FromSeconds(2)));
|
|
|
|
Task firstDispose = Task.Run(streamer.Dispose);
|
|
Task secondDispose = Task.Run(streamer.Dispose);
|
|
await Task.Delay(50);
|
|
Assert.False(firstDispose.IsCompleted);
|
|
Assert.False(secondDispose.IsCompleted);
|
|
|
|
release.Set();
|
|
await Task.WhenAll(firstDispose, secondDispose).WaitAsync(TimeSpan.FromSeconds(2));
|
|
}
|
|
finally
|
|
{
|
|
release.Set();
|
|
streamer.Dispose();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void DisposeRejectsEveryEnqueueKind()
|
|
{
|
|
var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
|
streamer.Start();
|
|
streamer.Dispose();
|
|
|
|
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueLoad(0x12340000u));
|
|
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueUnload(0x12340000u));
|
|
Assert.Throws<ObjectDisposedException>(streamer.ClearPendingLoads);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentStartAndDisposeLeaveAClosedStreamer()
|
|
{
|
|
for (int iteration = 0; iteration < 20; iteration++)
|
|
{
|
|
var streamer = new LandblockStreamer(loadLandblock: _ => null);
|
|
Exception? startFailure = null;
|
|
|
|
Task start = Task.Run(() =>
|
|
{
|
|
try { streamer.Start(); }
|
|
catch (ObjectDisposedException ex) { startFailure = ex; }
|
|
});
|
|
Task dispose = Task.Run(streamer.Dispose);
|
|
await Task.WhenAll(start, dispose).WaitAsync(TimeSpan.FromSeconds(2));
|
|
|
|
Assert.True(startFailure is null or ObjectDisposedException);
|
|
Assert.Throws<ObjectDisposedException>(() => streamer.EnqueueLoad(0x12340000u));
|
|
streamer.Dispose();
|
|
}
|
|
}
|
|
|
|
private static async Task<LandblockStreamResult> DrainFirstAsync(LandblockStreamer streamer)
|
|
{
|
|
for (int i = 0; i < SpinMaxIterations; i++)
|
|
{
|
|
var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize);
|
|
if (drained.Count > 0) return drained[0];
|
|
await Task.Delay(SpinStepMs);
|
|
}
|
|
|
|
throw new Xunit.Sdk.XunitException("Timed out waiting for streamer completion.");
|
|
}
|
|
}
|