Skip to content

UE 5.6: FFrameEndSync forces task pumping via FRenderThreadFence destructor, causing unsafe continuation execution during map loading #43

@CMesonArana

Description

@CMesonArana

Upgrading to Unreal Engine 5.6 causes crashes when using SDFutureExtensions continuations (.Then()) during level transitions. The crash occurs because UE 5.6 changed FlushRenderingCommands() from using a simple fence wait to using FFrameEndSync::Sync(), which forces aggressive GameThread task pumping through FRenderThreadFence destructors during RHI flushes. This is particularly problematic during map loading, as the GameInstance's world is set to null while TrimMemory() executes, but continuations are forced to run immediately in this invalid state, causing crashes when trying to access UObjects.

Issue Description

When using async continuations with the default execution policy during operations that trigger level transitions:

// Called within a game instance subsystem
OperationAsync()
    .Then(this, [this](const SD::TExpected<voi>& Result) {
        // CRASH: This executes during map load while GameInstance->GetWorld() is NULL
        // Any UObject access fails because world is invalid
        UWorld* World = GetWorld();  // Returns NULL!
        // Accessing subsystems, actors, or any world-dependent code crashes
    });

Crash Callstack:

OperationAsync().Then() continuation
→ SD::FutureExtensionTaskGraph::ExecuteContinuationFunction()
→ TGraphTask::ExecuteTask()
→ FBaseGraphTask::Execute()
→ FNamedTaskThread::ProcessTasksNamedThread()
→ FNamedTaskThread::ProcessThreadUntilIdle()  ← PUMPS MAIN QUEUE
→ GameThreadWaitForTask()
→ FRenderCommandFence::Wait(true)  ← bProcessGameThreadTasks = TRUE
→ ~FRenderThreadFence()  ← DESTRUCTOR!
→ FFrameEndSync::Sync()
→ FlushRenderingCommands()
→ UEngine::TrimMemory()  ← WORLD IS NULL HERE
→ UEngine::LoadMap()

The Critical Issue: World is Null During TrimMemory

During map loading, UEngine::LoadMap() follows this sequence:

void UEngine::LoadMap(FWorldContext& WorldContext, FURL URL, ...)
{
    // 1. Tear down old world
    WorldContext.SetCurrentWorld(nullptr);  // ← WORLD IS NOW NULL
    
    // 2. Free GPU memory from previous map
    TrimMemory();  // ← CALLED WHILE WORLD IS NULL
      → FlushRenderingCommands()
        → FFrameEndSync::Sync()
          → ~FRenderThreadFence()
            → Wait(true)  // ← FORCES TASK PUMPING
              → ProcessThreadUntilIdle(GameThread)
                → YOUR CONTINUATIONS EXECUTE HERE  // ← CRASH: WORLD IS NULL!
    
    // 3. Load new world (never reached if continuation crashes)
    // ...
}

The problem: Between tearing down the old world and loading the new world, the GameInstance's GetWorld() returns nullptr. If your continuations execute during this window and try to access the world, subsystems, or any world-dependent objects, they will crash.

Root Cause: The Critical Change in FlushRenderingCommands()

UE 5.5.2 (Old - Working):

void FlushRenderingCommands()
{
    // ...
    
    // Issue a fence command to the rendering thread and wait for it to complete.
    FRenderCommandFence Fence;
    Fence.BeginFence();
    Fence.Wait();  // ← Uses default parameter: bProcessGameThreadTasks = FALSE
}

Key point: FRenderCommandFence::Wait() has this signature:
void Wait(bool bProcessGameThreadTasks = false) const;
When called without arguments, it does NOT pump the GameThread task queue. It just blocks passively. Result: Continuations stay queued and execute later, after the new world is loaded and valid.

UE 5.6 (New - Breaking):

void FlushRenderingCommands()
{
    // ...
    
    // Issue a fence command to the rendering thread and wait for it to complete.
    // Use the frame end sync here, so that it cleans up outstanding graph events, 
    // which is necessary on engine shutdown.
    FFrameEndSync::Sync(FFrameEndSync::EFlushMode::Threads);  ← THE PROBLEM
}

The Breaking Mechanism: FRenderThreadFence Destructor
Inside FFrameEndSync::Sync(), this code runs:

void Sync(EFlushMode FlushMode)
{
    bool bFullSync = FlushMode == EFlushMode::Threads;  // TRUE when called from FlushRenderingCommands
    
    // Always sync with the render thread (either current frame, or N-1 frame)
    RenderThreadFences.Emplace();  // Creates a FRenderThreadFence
    
    while (RenderThreadFences.Num() > (bFullSync ? 0 : 1))  // bFullSync = true, so loop until empty
    {
        RenderThreadFences.RemoveAt(0);  // ← DESTRUCTOR CALLED HERE!
    }
    
    // ... more code ...
}

The killer: RenderThreadFences.RemoveAt(0) calls the FRenderThreadFence destructor:

struct FRenderThreadFence
{
    // Legacy game code assumes the game thread will never get further than 1 frame ahead of the render thread.
    // This fence is used to sync the game thread with the N-1 render thread frame.
    FRenderCommandFence Fence;

    FRenderThreadFence()
    {
        Fence.BeginFence(ESyncDepth::RenderThread);
    }

    ~FRenderThreadFence()  // ← DESTRUCTOR
    {
        Fence.Wait(true);  // ← FORCES bProcessGameThreadTasks = TRUE!
    }
};

The destructor calls Fence.Wait(true), which unconditionally pumps the GameThread main queue - even though the world is null.

Complete Flow During Map Loading

1. UEngine::LoadMap()
   ├─ WorldContext.SetCurrentWorld(nullptr)   // ← WORLD IS NOW NULL
   │
   └─ UEngine::TrimMemory()                   // Free GPU memory (world still null)
      └─ FlushRenderingCommands()              // Wait for GPU (world still null)
         └─ FFrameEndSync::Sync(EFlushMode::Threads)
            ├─ RenderThreadFences.Emplace()        // Create fence #1
            │  └─ FRenderThreadFence() constructor
            │     └─ Fence.BeginFence(ESyncDepth::RenderThread)
            │
            ├─ while (RenderThreadFences.Num() > 0)  // bFullSync = true
            │  └─ RenderThreadFences.RemoveAt(0)
            │     └─ ~FRenderThreadFence()      ← DESTRUCTOR!
            │        └─ Fence.Wait(true)        ← TRUE FORCED!
            │           └─ GameThreadWaitForTask(Task, true)
            │              └─ Loop:
            │                 ├─ CheckRenderingThreadHealth()
            │                 └─ ProcessThreadUntilIdle(GameThread)  ← PUMPS QUEUE!
            │                    └─ Your .Then() continuation EXECUTES HERE
            │                       ├─ GetWorld() → nullptr  ← WORLD IS NULL!
            │                       ├─ GetSubsystem() → CRASH
            │                       └─ Access any UObject → CRASH
            │
            └─ PipelineFences wait loop
               └─ More forced pumping...

SDFutureExtensions schedules continuations to the GameThread main queue (QueueIndex 0) by default:
// ExpectedFuture.h - GetExecutionDetails()

case EExpectedFutureExecutionPolicy::Current:
default:
    return FExecutionDetails(
        EExpectedFutureExecutionPolicy::Current, 
        FTaskGraphInterface::Get().GetCurrentThreadIfKnown()  // Returns GameThread with MainQueue
    );

When ProcessThreadUntilIdle(ENamedThreads::GameThread) is called (forced by the destructor), it processes all tasks in the main queue, including continuations.

We have been doing some tests by updating future's plugin to target GameThread_Local queue, or by explicitely calling FTaskGraphInterface::Get().ProcessThreadUntilIdle(ENamedThreads::GameThread); before triggering the load request, but I was wondering if you have noticed this too.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions