Skip to content

fix(codegen/unreal): eliminate UObject memory leak in reducer event handlers#3987

Merged
bfops merged 4 commits intoclockworklabs:masterfrom
brougkr:fix/unreal-reducer-uobject-memory-leak
Jan 13, 2026
Merged

fix(codegen/unreal): eliminate UObject memory leak in reducer event handlers#3987
bfops merged 4 commits intoclockworklabs:masterfrom
brougkr:fix/unreal-reducer-uobject-memory-leak

Conversation

@brougkr
Copy link
Contributor

@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!

Copy link
Contributor

@JasonAtClockwork JasonAtClockwork left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested and is a solid change to the codegen for Unreal modules. Thanks again for this great fix!

@JasonAtClockwork JasonAtClockwork added this pull request to the merge queue Jan 12, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Jan 12, 2026
@bfops bfops added this pull request to the merge queue Jan 13, 2026
Merged via the queue into clockworklabs:master with commit 09ff117 Jan 13, 2026
22 checks passed
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>
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.

4 participants