-
Notifications
You must be signed in to change notification settings - Fork 30
Description
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.