Skip to content

Conversation

@brougkr
Copy link

@brougkr brougkr commented Jan 9, 2026

Summary

  • Fixes critical memory leak in Unreal C++ generated code where NewObject<UReducer>() is called for every reducer event but never cleaned up
  • At 30Hz server tick rate (common for game servers), this creates ~30 orphaned UObjects per second, causing continuous memory growth
  • Solution: Pass stack-allocated FArgs struct directly via new InvokeWithArgs() method instead of allocating UObjects

Problem

The generated ReducerEvent handler in SpacetimeDBClient.g.cpp creates a new UObject for every reducer event:

FArgs Args = ReducerEvent.Reducer.GetAs...();
UReducer* Reducer = NewObject<UReducer>();  // LEAK - never cleaned up!
Reducer->Param = Args.Param;
Reducers->Invoke...(Context, Reducer);

Unreal's garbage collector cannot keep up at high frequencies. Attempts to fix with MarkAsGarbage(), ConditionalBeginDestroy(), and transient package flags all failed because GC timing is unpredictable.

Solution

Eliminate UObject allocation entirely by passing FArgs directly:

FArgs Args = ReducerEvent.Reducer.GetAs...();
Reducers->Invoke...WithArgs(Context, Args);  // Zero allocation!

The original Invoke() methods that take UReducer* are preserved for backwards compatibility.

Changes

  1. Header generation (~line 2515): Add InvokeWithArgs() method declaration
  2. ReducerEvent handler (~line 3194): Call InvokeWithArgs() instead of creating UObject
  3. Implementation (~line 3703): Add InvokeWithArgs() that extracts params from FArgs struct

Test Plan

  • Verified with 30Hz TickGameScheduled reducer - UObject count remains stable
  • Memory growth eliminated (was ~1000 UObjects per 14 seconds)
  • Backwards compatibility preserved - existing Invoke() methods still work

Before/After

Before: OBJS count in Unreal stats continuously increasing, memory growing unbounded
After: UObjDelta=0 - no UObject growth from reducer events

…andlers

The Unreal C++ code generator creates NewObject<UReducer>() for every
reducer event received from the server, but these UObjects are never
properly cleaned up. At a 30Hz server tick rate (common for game servers),
this creates ~30 orphaned UObjects per second, leading to continuous
memory growth and eventually degraded performance.

The fix eliminates UObject allocation entirely by passing the stack-
allocated FArgs struct directly through a new InvokeWithArgs() method.
The original Invoke() methods that take UReducer* are preserved for
backwards compatibility with any existing user code.

Changes:
- Add InvokeWithArgs() method declaration to URemoteReducers (line ~2515)
- Modify ReducerEvent handler to call InvokeWithArgs() instead of
  creating UReducer UObject (line ~3194)
- Add InvokeWithArgs() method implementation that extracts params from
  FArgs struct and broadcasts to delegate (line ~3703)

Before (leaking):
    FArgs Args = ReducerEvent.Reducer.GetAs...();
    UReducer* Reducer = NewObject<UReducer>();  // LEAK!
    Reducer->Param = Args.Param;
    Reducers->Invoke...(Context, Reducer);

After (fixed):
    FArgs Args = ReducerEvent.Reducer.GetAs...();
    Reducers->Invoke...WithArgs(Context, Args);

Tested with 30Hz scheduled reducer - UObject count remains stable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@CLAassistant
Copy link

CLAassistant commented Jan 9, 2026

CLA assistant check
All committers have signed the CLA.

@JasonAtClockwork JasonAtClockwork self-requested a review January 9, 2026 19:49
@JasonAtClockwork
Copy link
Contributor

Thanks again for this PR I think it looks good but running into issues on my device to properly test it, I should have this confirmed on Monday!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants