[Niagara][NDC] Cached UNiagaraDataChannelReader pins FNiagaraDataChannelData indefinitely, permanently blocking GameplayBurst/Island entry retirement and shared-system respawn

ROOT CAUSE — the chain

1. The Reader is a per-handler singleton, created lazily and cached forever.
       NiagaraDataChannelHandler.cpp:100-108

2. Its member is the channel data pointer itself, not a copy or a weak reference.
       FNiagaraDataChannelDataPtr Data = nullptr;   // NiagaraDataChannelAccessor.h:25
   and it is assigned on every access:
       Data = Owner->FindData(AccessContext, ENiagaraResourceAccess::ReadOnly);
       // NiagaraDataChannelAccessor.cpp:53-58 (BeginRead), :41-49 (InitAccess)

3. Nothing ever clears it. The natural release point exists but is empty:
       void UNiagaraDataChannelHandler::EndFrame(float DeltaTime)
       {
       }
       // NiagaraDataChannelHandler.cpp:68-71

4. Retirement is blocked by exactly that reference, and the check is evaluated BEFORE
   the "are the spawned systems complete?" check, so completion cannot compensate:
       bInUse = Data.IsUnique() == false;                  // Map.cpp:328
       if (!bInUse) { /* only now look at SpawnedComponents */ }   // Map.cpp:331-341
   A blocked entry is then reused as-is, with no re-initialization and no re-spawn:
       int32* ActiveEntry = ActiveEntries.Find(Key);
       if (ActiveEntry) { Ret = &EntryPool[*ActiveEntry]; }  // Map.cpp:219-223
   Init()/SpawnSystem() only run on the newly-created path (Map.cpp:246), so the
   bucket's shared system is never recreated.


WHY THERE IS NO WORKAROUND

UNiagaraDataChannelReader::Data is private (friend UNiagaraDataChannelHandler), and the
only method that clears it is:
       void Cleanup();                               // NiagaraDataChannelAccessor.h:33
       void UNiagaraDataChannelReader::Cleanup() { Data = nullptr; }  // Accessor.cpp:36-39

The class is UCLASS(BlueprintType, MinimalAPI) (Accessor.h:18) and Cleanup() carries no
NIAGARA_API, while its siblings InitAccess/BeginRead do (Accessor.h:43, :46). So:
  - C++ in another module: LNK2019 unresolved external symbol.
  - Blueprint: Cleanup() is not BlueprintCallable, although the rest of this Reader API is.
There is therefore no supported way for a caller to release the reference it caused.


REPRO PATH A — a single read

Any call to Read From Niagara Data Channel (With Context) against a GameplayBurst/Island
location pins that bucket. Game code cannot undo it (see above).


REPRO PATH B — no read call at all, reachable from pure Blueprint

NotifySubscribers writes into the same cached Reader and never clears it after the
broadcast loop:
       UpdateContext.Reader = GetDataChannelReader();   // NiagaraDataChannelHandler.cpp:172
       ...
       UpdateContext.Reader->Data = ChannelData;        // NiagaraDataChannelHandler.cpp:197

SubscribeToDataChannelUpdates / _WithContext are BlueprintCallable
(NiagaraDataChannelHandler.h:72-78). So simply subscribing to a GameplayBurst channel
from Blueprint pins the last-notified bucket indefinitely, without the project ever
reading from the channel. This path is entirely inside the engine.

Summary

UNiagaraDataChannelHandler caches a single UNiagaraDataChannelReader for its entire
lifetime, and that Reader stores a strong FNiagaraDataChannelDataPtr to whichever
bucket/island was accessed last. The engine never releases this reference: there is no
frame-boundary cleanup, and the only method that would release it is neither exported
nor Blueprint-callable, so no caller can release it either.

Because retirement is gated on that exact shared pointer being unique, the referenced
bucket can never retire. Its spawned Niagara System eventually completes on its own,
but the entry stays active, and every later write to that location reuses the dead
entry without re-spawning. The result is a bucket that silently stops visualizing for
the remaining lifetime of the World, while writes keep succeeding with no errors or
warnings.

What Type of Bug are you experiencing?

Rendering (Graphics / Niagara)

Steps to Reproduce

  1. Create a Niagara Data Channel asset with Channel Type = GameplayBurst.
    Set a System To Spawn whose emitters complete when unused (default behavior), and set
    the grid Cell Size large enough that every test location falls into one bucket (this
    matters — see “Reproducing reliably” below).
  2. From Blueprint, on a timer, call Write To Niagara Data Channel (With Context) at a
    fixed world location. Confirm the shared system spawns and renders.
  3. Introduce the reference, using either path:
    A) after each write, also call Read From Niagara Data Channel (With Context) at the
    same location; or
    B) do not read at all — just call Subscribe To Data Channel Updates once for that
    channel/location.
  4. Stop writing for a few seconds and let the spawned system reach Complete.
  5. Resume writing to the same location.

Expected Result

The entry retires once its spawned components are all IsComplete(), so the write in step
5 re-runs Init()/SpawnSystem() and the shared system respawns and renders again.

Observed Result

The shared system never spawns again for that bucket. Writes keep returning a valid
writer and report no error; Niagara Debugger shows no spawned system for the bucket from
then on. Data.IsUnique() never becomes true again, so BeginFrame keeps reporting the
entry as in use (Map.cpp:328) and FindOrAddEntry keeps handing back the dead entry
(Map.cpp:219-223).

Without step 3 the same test passes: entries retire and the shared system respawns
correctly. The write path does not participate in this reference counting, because
UNiagaraDataChannelWriter::Data is a different type (FNiagaraDataChannelGameDataPtr,
Accessor.h:95) and BeginWrite’s FNiagaraDataChannelDataPtr is a function-local that is
released at scope exit (Accessor.cpp:215-228). We confirmed this asymmetry in a shipping
project: with reads removed and writes unchanged, buckets retire and respawn as expected.

Affects Versions

5.8

Platform(s)

Windows