fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
3971997689
commit
749e8ceeb1
225 changed files with 29107 additions and 3914 deletions
|
|
@ -57,8 +57,12 @@ public sealed class LandblockStreamer : IDisposable
|
|||
private readonly Channel<LandblockStreamJob> _inbox;
|
||||
private readonly Channel<LandblockStreamResult> _outbox;
|
||||
private readonly CancellationTokenSource _cancel = new();
|
||||
private readonly object _inboxGate = new();
|
||||
private Thread? _worker;
|
||||
private Exception? _workerFailure;
|
||||
private int _disposed;
|
||||
private readonly object _disposeGate = new();
|
||||
private bool _disposeCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
|
||||
|
|
@ -75,7 +79,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
// 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>(
|
||||
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
|
|
@ -117,25 +121,26 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// <summary>
|
||||
/// Activate the dedicated background worker thread. Idempotent and
|
||||
/// thread-safe: concurrent callers will only spawn one worker; subsequent
|
||||
/// calls are no-ops. Atomic via <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>.
|
||||
/// calls are no-ops. Serialized with disposal so a worker can never start
|
||||
/// after the owning DAT lifetime has been released.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
|
||||
// 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)
|
||||
lock (_disposeGate)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "acdream.streaming.worker",
|
||||
};
|
||||
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
|
||||
candidate.Start();
|
||||
// else: another caller won the race; their thread is running.
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
if (_worker is not null)
|
||||
return;
|
||||
|
||||
var worker = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "acdream.streaming.worker",
|
||||
};
|
||||
worker.Start();
|
||||
_worker = worker;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -151,7 +156,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}");
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind, generation));
|
||||
WriteJob(new LandblockStreamJob.Load(landblockId, kind, generation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -160,9 +165,7 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// </summary>
|
||||
public void EnqueueUnload(uint landblockId, ulong generation = 0)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId, generation));
|
||||
WriteJob(new LandblockStreamJob.Unload(landblockId, generation));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -176,9 +179,25 @@ public sealed class LandblockStreamer : IDisposable
|
|||
/// </summary>
|
||||
public void ClearPendingLoads()
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
_inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads());
|
||||
WriteJob(new LandblockStreamJob.ClearLoads());
|
||||
}
|
||||
|
||||
private void WriteJob(LandblockStreamJob job)
|
||||
{
|
||||
// Serialize the writer's terminal transition with enqueue. Without
|
||||
// this small lifecycle gate a worker crash could complete the channel
|
||||
// between the caller's state check and TryWrite, silently dropping a
|
||||
// landblock request. Enqueues are destination-boundary events, not a
|
||||
// per-frame hot path.
|
||||
lock (_inboxGate)
|
||||
{
|
||||
if (System.Threading.Volatile.Read(ref _disposed) != 0)
|
||||
throw new ObjectDisposedException(nameof(LandblockStreamer));
|
||||
if (_workerFailure is { } failure)
|
||||
throw new InvalidOperationException("The landblock streaming worker has terminated.", failure);
|
||||
if (!_inbox.Writer.TryWrite(job))
|
||||
throw new InvalidOperationException("The landblock streaming inbox is no longer accepting work.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -248,6 +267,11 @@ public sealed class LandblockStreamer : IDisposable
|
|||
{
|
||||
// Last-ditch: surface via outbox so the caller at least sees
|
||||
// something. We never retry a crashed worker.
|
||||
lock (_inboxGate)
|
||||
{
|
||||
_workerFailure = ex;
|
||||
_inbox.Writer.TryComplete(ex);
|
||||
}
|
||||
_outbox.Writer.TryWrite(new LandblockStreamResult.WorkerCrashed(ex.ToString()));
|
||||
}
|
||||
finally
|
||||
|
|
@ -397,18 +421,21 @@ public sealed class LandblockStreamer : IDisposable
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
_cancel.Cancel();
|
||||
_inbox.Writer.TryComplete();
|
||||
// Generous join: the owner disposes the DatCollection after this, which
|
||||
// unmaps the dats' memory-mapped views — an abandoned worker mid-dat-read
|
||||
// would take the process down with an AccessViolation in
|
||||
// MemoryMappedBlockAllocator.ReadBlock (dat-race investigation 2026-06-09).
|
||||
// Cancellation is honored between jobs, so the wait is bounded by one
|
||||
// landblock load; 15s only ever elapses if the worker is genuinely hung.
|
||||
if (_worker is not null && !_worker.Join(TimeSpan.FromSeconds(15)))
|
||||
Console.Error.WriteLine(
|
||||
"[streamer] worker did not stop within 15s — dat teardown may race an in-flight load");
|
||||
_cancel.Dispose();
|
||||
lock (_disposeGate)
|
||||
{
|
||||
if (_disposeCompleted)
|
||||
return;
|
||||
|
||||
System.Threading.Interlocked.Exchange(ref _disposed, 1);
|
||||
_cancel.Cancel();
|
||||
lock (_inboxGate)
|
||||
_inbox.Writer.TryComplete();
|
||||
// The owner releases the memory-mapped DAT immediately after this
|
||||
// object. Join the actual worker without a grace-period timeout so
|
||||
// no native read can survive into that teardown.
|
||||
_worker?.Join();
|
||||
_cancel.Dispose();
|
||||
_disposeCompleted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue