FTaskBase::Close()/TryUnlock() mutual recursion is unbounded — stack overflow SIGSEGV on Android with long linear task chains

The unbounded recursion is a property of the task system itself, not of any one caller. Any subsystem that builds a sufficiently long linear chain of prerequisites (or a deep nested-task hierarchy — TryUnlock’s parent-unlock path recurses identically) can trigger it, and the resulting crash signature is identical and unattributable in all cases. Capping or draining at individual call sites requires finding every chain builder in the engine and in licensee code, and each such fix is a per-site mitigation rather than a fix for the class of bug. Making Close() iterative would bound stack usage to O(1) regardless of chain length and protect every caller simultaneously.

[Attachment Removed]

Steps to Reproduce

Crash Report Summary

Platform Information

Platform: Android (arm64), Vulkan ES3.1 mobile feature level

Frequency: 264 events / 133 unique users over 90 days, ongoing since Aug 2025

Severity: Crash (unhandled SIGSEGV), player-facing, reproduces across device vendors

Summary

FTaskBase::Close() and FTaskBase::TryUnlock() are mutually recursive with no depth bound. Closing the head of a linear task chain of length N consumes N stack frames. On Android, where the default pthread stack is 1 MB, sufficiently long chains overflow the stack and terminate the process with SIGSEGV (SEGV_ACCERR — consistent with hitting the PROT_NONE guard page).

TaskPrivate.h (5.5.4):

void Close()
{
...
for (FTaskBase* Subsequent : Subsequents.Close())
{
Subsequent->TryUnlock(bWakeUpWorker); // (A)
}
...
}
 
bool TryUnlock(bool& bWakeUpWorker)
{
...
// execution already started, this is nested tasks unlocking their parent
...
Close(); // (B)
Release();
...
}

(A) → (B) → (A) → … recurses once per chain link. Neither function has a depth guard, and the recursion in (A) is not in tail position.

Note that FSubsequents is declared as:

FSubsequents> Subsequents;The inline allocator sized for exactly one element indicates the expected common case is one task → one subsequent. In that shape, a chain of N tasks recurses N frames deep with no branching to bound it.

Stack Trace (Symbolicated, Tombstone-Derived)

Fatal signal SIGSEGV (11), SEGV_ACCERR (2), pid = 24739

Crashing thread: “ForegroundWorker #1” (LowLevelTasks scheduler worker)

libUnreal UE::Tasks::Private::FTaskBase::FSubsequents::Close (TaskPrivate.h:778)
libUnreal UE::Tasks::Private::FTaskBase::Close (TaskPrivate.h:539)
libUnreal UE::Tasks::Private::FTaskBase::TryUnlock (TaskPrivate.h:654)
libUnreal UE::Tasks::Private::FTaskBase::Close (TaskPrivate.h:543)
libUnreal UE::Tasks::Private::FTaskBase::TryUnlock (TaskPrivate.h:654)
... [Close/TryUnlock pair repeats until the captured frame limit] ...

The trace is truncated by the tombstone frame cap before reaching the entry point, so the originating subsystem is not visible in any of the ~260 captured events. This is itself a diagnostic problem: the failure mode erases the evidence needed to attribute it.

Reproduction Pattern

Consistent across ~12 months of internal reports (CL 10476 → CL 44399):

  1. Application sits with the renderer running but no scene-destruction or shutdown event occurring — main menu, landing page, error dialog, CDN download screen, or a low-activity client in a networked match.
  2. Idle duration ranges from ~2.5 min to ~28 min across confirmed captures.
  3. A state transition occurs (screen change, scene teardown, app resume).
  4. SIGSEGV within milliseconds of that transition.

Confirmed device log (Samsung Z Fold 6 / SM-F956B):

07:08:18 Landing page shown
07:08:18 → 07:15:26 idle, no logged activity (~7 min)
07:15:26 SIGSEGV

A separate confirmed capture showed ~28 min idle (including an app background/foreground cycle, which did not reset the accumulation) followed by crash within ~73 ms of the screen transition.

During investigation we independently identified CL 43539816, which adds the missing `FAsyncDeleter::LastTask = {}` reset in FRDGBuilder::WaitForAsyncDeleteTask(). We have not yet integrated it, but we believe it is a genuine and necessary fix: without it, the RDG async-delete chain is never severed even at its drain points (FScene destruction, rendering-thread stop, engine shutdown) and grows for the process lifetime. However, we do not believe it resolves this crash on mobile, because IsParallelExecuteEnabled() (RenderGraphPrivate.cpp) contains: && !IsMobilePlatform(GMaxRHIShaderPlatform) so on Android, ParallelExecute.TaskMode never leaves Inline, FRDGBuilder::~FRDGBuilder() never assigns AsyncDeleter.Function, and the entire FAsyncDeleter chain is inert. We verified this empirically: a depth-instrumented build logs continuous chain growth in the Editor (~180 links/sec) and zero occurrences across multi-minute Android sessions.

[Attachment Removed]

Hi Zaib,

Can you confirm which CL / UE version you experienced this on? As you CL 43539816 as a potential candidate fix, you are on 5.6? //UE5/Main isn’t recommended for licensee development as that is the Epic internal development stream which can present instability.

Best regards.

[Attachment Removed]