fix(codegen/unreal): eliminate UObject memory leak in reducer event handlers#3987
Merged
bfops merged 4 commits intoclockworklabs:masterfrom Jan 13, 2026
Merged
Conversation
…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>
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! |
JasonAtClockwork
approved these changes
Jan 12, 2026
Contributor
JasonAtClockwork
left a comment
There was a problem hiding this comment.
Tested and is a solid change to the codegen for Unreal modules. Thanks again for this great fix!
…ub.com/brougkr/SpacetimeDB into fix/unreal-reducer-uobject-memory-leak
kistz
pushed a commit
to kistz/SpacetimeDB
that referenced
this pull request
Jan 13, 2026
…andlers (clockworklabs#3987) ## 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: ```cpp 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: ```cpp 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 - [x] Verified with 30Hz `TickGameScheduled` reducer - UObject count remains stable - [x] Memory growth eliminated (was ~1000 UObjects per 14 seconds) - [x] 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 --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Jason Larabie <jason@clockworklabs.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
NewObject<UReducer>()is called for every reducer event but never cleaned upFArgsstruct directly via newInvokeWithArgs()method instead of allocating UObjectsProblem
The generated
ReducerEventhandler inSpacetimeDBClient.g.cppcreates 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
FArgsdirectly:FArgs Args = ReducerEvent.Reducer.GetAs...(); Reducers->Invoke...WithArgs(Context, Args); // Zero allocation!The original
Invoke()methods that takeUReducer*are preserved for backwards compatibility.Changes
InvokeWithArgs()method declarationInvokeWithArgs()instead of creating UObjectInvokeWithArgs()that extracts params fromFArgsstructTest Plan
TickGameScheduledreducer - UObject count remains stableInvoke()methods still workBefore/After
Before:
OBJScount in Unreal stats continuously increasing, memory growing unboundedAfter:
UObjDelta=0- no UObject growth from reducer events