The problem
If a function hands control to code it doesn’t own in the middle of an update (a delegate, a multicast Broadcast, a visitor, a BlueprintImplementableEvent), that code can call the function again. Usually nothing crashes and nothing reports it. The nested call sees state the outer call is halfway through changing, and guarantees like event order quietly break. You find out later, when the game behaves slightly wrong.
The fix
The fix is two lines at the top of the function. Here they are in the drain loop of my Chronos scheduler (the guard lines are verbatim; the loop is simplified):
int32 FChronosSchedule::Drain(const FDateTime& Now, TFunctionRef<void(const FChronosFiredEvent&)> Visitor)
{
checkf(!bIsDraining, TEXT("FChronosSchedule::Drain must not be called from its own visitor."));
TGuardValue<bool> DrainingGuard(bIsDraining, true);
int32 FiredCount = 0;
while (HasDueEvent(Now))
{
const FChronosFiredEvent Fired = PopDueEvent(Now);
++FiredCount;
Visitor(Fired); // code you don't control runs here
}
return FiredCount;
}bIsDraining is a plain bool member, initialized to false. If a visitor calls Drain again, the nested call would fire the next due events before the visitor has finished with the current one. Instead, the nested checkf sees true and stops right there, with the cause in the log rather than a symptom three systems away.
How it works
TGuardValue (Templates/UnrealTemplate.h) is a small RAII helper. The constructor saves the current value and assigns the new one. The destructor restores the saved value when execution leaves the scope, including through an early return. Resetting the flag by hand works until someone adds an early return and the flag stays true for good. TGuardValue also restores the original value, not false, which matters once guards nest or the type isn’t bool.
checkf (Misc/AssertionMacros.h) is a fatal assert. On failure it records the expression, file, line and your message. With a debugger attached it breaks on that line (and you can step past it); without one it raises a fatal error and the process exits, with the callstack in the crash report. It only exists when DO_CHECK is on. Misc/Build.h turns that on in Debug and Development (including the editor); Test and Shipping follow USE_CHECKS_IN_SHIPPING, which defaults to 0. When checks are compiled out, the expression still has to compile but never runs.
Choosing how to handle re-entry
The flag and guard stay the same; only how you handle re-entry changes:
// A. Programming error: fatal in Debug/Development, compiled out in Test/Shipping by default.
checkf(!bIsBusy, TEXT("Foo() called from its own callback."));
TGuardValue<bool> Guard(bIsBusy, true);
// B. Programming error, but never take the game down: report (first time only) and skip.
if (!ensureMsgf(!bIsBusy, TEXT("Foo() re-entered; call ignored.")))
{
return;
}
TGuardValue<bool> Guard(bIsBusy, true);
// C. Not an error: re-entry is ignored by design.
if (bIsBusy)
{
return;
}
TGuardValue<bool> Guard(bIsBusy, true);B works in every configuration because ensureMsgf still evaluates its condition when DO_ENSURE is off; only the report goes away. C turns the guard into a game rule. If two characters both have Thorns and each reflects the full hit back at the attacker, damage would bounce between them until the stack overflows. With a per-character if (bIsApplyingDamage) return; at the top of the damage function, a character still processing a hit ignores the damage that bounces back, so the exchange stops after one reflection.
Sometimes the right answer is no guard at all:
- Recursion is intended. A tree walk that recurses through the same object would trip the guard on its first child.
- The risk is another thread. A
boolisn’t a lock. - The nested call’s work still has to happen. Defer it to the next pass, or iterate over a snapshot instead.