fix(data-store): plug the FFI leak, NUL-terminate the Go get result, skip empty syncs - #30
Conversation
Three C strings handed across the DataStoreC boundary were never reclaimed.
string_to_c_char is CString::new(s).into_raw(), which transfers ownership to
the caller, and free_string sits directly below it in ffi_utils, but none of
the three call sites used it:
- set: args_json is the full serialized specs. For a large project that is
~18MB, leaked once per specs write. With a data store configured SpecStore
writes on the sync timer, so this is tens of MB a minute of unreclaimable
native memory for the process lifetime. Observed in production as ~45MB/min
of RSS growth per pod, sawtoothing into the cgroup limit and OOMKilling
every ~20 minutes.
- get: the key, small but on the same timer.
- support_polling_updates_for: the request path, same.
The Go get callback also returned a slice that was neither NUL-terminated nor
guaranteed non-empty, while the Rust side reads it back with CStr::from_ptr:
- No terminator means from_ptr runs past the end of the slice until it
happens to find a zero byte. It survives today only because Go zeroes
large fresh allocations.
- A zero-length result panics on &result[0] inside a purego callback. That
makes it impossible for an adapter to signal "no data", even though the
Rust side already handles it: a nil DataStoreResponse.result becomes
DataStoreFailure and falls back to the network specs adapter.
Appending a single 0 byte fixes both.
Not addressed here: the callback still returns a pointer into Go-managed
memory with nothing keeping it alive past the return, which the Rust side
then reads. That needs an FFI signature change.
dfb8e7c to
21c06c8
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 21c06c8. Configure here.
…ding them start() rejects a nil DataStoreResponse.result with DataStoreFailure, letting StatsigCustomizedSpecsAdapter fall through to the network adapter. execute_background_sync_impl has no equivalent check, so a nil result flows into send_specs_update_to_listener, where unwrap_or_default() turns it into an empty body. set_values can only fail to deserialize that, once per sync tick, for as long as the data store has nothing to serve. Nothing is corrupted — the failure happens during Prep, before the write lock, so previously loaded specs survive — but the sync is pure noise. Bail on a nil result and say so, matching what start() already does.
Freeing in DataStoreC would have double-freed on .NET. statsig-dotnet already reclaims the same pointer in the finally block of GetNative, SetNative and SupportPollingNative, so the established contract is that the callback owns the args and frees them. Go was simply the binding that never held up its end. Revert the Rust frees and put a deferred free_string in each of the three Go callbacks instead, matching statsig-dotnet. free_string was already bound in statsig_ffi.go, so nothing new is exported. Same leak fixed, correct side of the boundary: the Set args are the full serialized specs, ~18MB for a large project, leaked once per write.
There was a problem hiding this comment.
claude is saying we have the original and 2 copies (line 99 and line 102), and we could get just one by doing the following, but maybe we can do it in a follow up?
if inputPtr == nil {
return nil, errors.New("nil data store set args")
}
var args dataStoreSetArgs
// Decode straight out of the C buffer: json.Unmarshal neither retains nor
// mutates its input, and the decoded fields are Go-owned copies, so this
// skips two full-size copies of the args.
if err := json.Unmarshal(unsafe.Slice(inputPtr, inputLength), &args); err != nil {
return nil, err
}
return &args, nil
There was a problem hiding this comment.
yeah this makes sense, addressed
| store.functions.Shutdown, | ||
| // Get | ||
| func(argPtr *byte, argLength uint64) *byte { | ||
| // The core hands these args over via CString::into_raw, so the |
There was a problem hiding this comment.
claude is also saying that persistent_storage.go needs these same fixes, if we want to add that to a follow up?
There was a problem hiding this comment.
did it here instead of a follow-up! it's the same three bugs and the same review
cshi-figma
left a comment
There was a problem hiding this comment.
left some comments, and maybe we can add some tests later?
but we can def do them in a follow up pr!
GoStringFromPointer copied the C buffer into a Go string and json.Unmarshal copied that string again, so a Set held four ~18MB allocations at once: the C buffer, the string, the byte slice, and the Value the decoder produced. Unmarshalling from unsafe.Slice drops the two middle ones. json.Unmarshal neither retains nor mutates its input and the decoded fields are Go-owned, so nothing points into the C buffer once it returns - and the free_string defer still runs after. Also guards a nil args pointer, which today dereferences a nil *string inside a purego callback.
| // NUL-terminated. The terminator also keeps the slice non-empty, | ||
| // so a "" from the adapter no longer panics on &result[0]. | ||
| result := append([]byte(store.functions.Get(*keyStr)), 0) | ||
| return &result[0] |
There was a problem hiding this comment.
claude is saying that &result[0] gives Rust memory thats not reachable from Go when the callback returns, and we are returning the pointer that Rust reads later, so the pointer might not still hold what we wrote to it when we read it
There was a problem hiding this comment.
it is suggesting this if helpful
type DataStore struct {
functions DataStoreFunctions
ref uint64
// The get callback hands Rust a pointer into this buffer, which Rust reads
// with CStr::from_ptr after the callback has already returned. A local would
// be unreachable by then. Released on the following get.
getResultMu sync.Mutex
lastGetResult []byte
}
func(argPtr *byte, argLength uint64) *byte {
defer GetFFI().free_string(argPtr)
keyStr := internal.GoStringFromPointer(argPtr, argLength)
if keyStr == nil {
return nil
}
raw := store.functions.Get(*keyStr)
result := make([]byte, len(raw)+1) // zero-filled, so NUL-terminated
copy(result, raw)
store.getResultMu.Lock()
store.lastGetResult = result
store.getResultMu.Unlock()
return &result[0]
},
There was a problem hiding this comment.
interestinggg! addressed (with help of claude). your suggested fix makes sense
The get callback returned &result[0] into a slice allocated inside the callback. The core reads that pointer with CStr::from_ptr after the callback has already returned, by which point nothing in Go references the slice and it is collectable - a use-after-free with a small window. Retain the buffer instead, in a slot released only when a later result displaces it. This is what statsig-dotnet does with its single pinned get-result handle, and it gives the NUL terminator somewhere durable to live. One slot is enough here: a get result is the cached specs, ~18MB for a large project, and StatsigDataStoreSpecsAdapter is the only caller and reads serially. An empty string from the adapter now returns nil rather than a pointer to a lone terminator, so a miss takes the DataStoreFailure path the core already handles instead of failing to deserialize "". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dings persistent_storage.go has every problem data_store.go had: - Load, Save and Delete never freed their args. The core hands them over with CString::into_raw and nothing on the Rust side reclaims them, so each call leaked. Freed with a deferred free_string, as in DataStore. - Load returned &json[0] - unterminated, so CStr::from_ptr reads past the end, and unreachable from Go once the callback returns. Now goes through ResultKeeper. Sixteen slots, not one: load runs synchronously during evaluation so concurrent evaluations mean concurrent callbacks, and a single user's sticky values are small. - tryMarshalPersistentStorageArgs copied the buffer twice before unmarshalling, and dereferenced a nil on a nil args pointer. Decodes straight out of the C buffer now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each test was checked against the pre-fix bindings, so none of them pass by accident: - ResultKeeper unit tests. The interesting one attaches a finalizer to a retained buffer and asserts it survives a GC with no reference to it but the keeper's - the exact property a slice local to the callback does not have. Its counterpart asserts a displaced buffer *is* released, so the keeper cannot quietly become a leak. - The get result is read back byte for byte, and an empty get result is a miss rather than a panic. Pre-fix, the empty case panicked with "index out of range [0] with length 0" inside the callback. - The set and save args are not leaked. Both drive the callback past the allocator's high-water mark and then measure RSS over the same number of calls again, which separates a per-call leak from ordinary heap noise: 125MB of growth pre-fix against under 1MB now. - Persistent storage loads are driven concurrently, since load runs on the evaluation path. Race-clean, and each caller reads back its own result. The pre-existing suite has one unrelated data race, in the test harness itself: mock_scrapi.go appends to m.events from concurrent handlers. Left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Three fixes on the data-store path. All present on upstream
maintoo -statsig-ffi/src/data_store_c.rshas not been touched since 2025-10-15.1. The Set args were leaked (the reason for this PR)
string_to_c_charisCString::new(s).into_raw(), which transfers ownership to the callback.statsig-dotnetreclaims it in thefinallyblock ofGetNative,SetNativeandSupportPollingNative.statsig-gonever did, so all three callback args leaked on every call.Setis the one that hurts:args_jsonis the full serialized specs - ~18MB for our project - and with a data store configuredSpecStorewrites on the sync timer, every 10-20s in our staging logs. That is 54-108MB/min of unreclaimable native memory for the process lifetime.It matches what we measured in production: ~45MB/min of RSS growth per pod, sawtoothing into whatever cgroup limit was set and OOMKilling every ~20 minutes. Go live heap stayed flat at ~100MiB against 680MB RSS, so ~85% was native and invisible to the Go profiler. It began the day a data store was first configured, and
Setonly runs when one is.Fixed with a deferred
free_stringin each of the three Go callbacks, matching statsig-dotnet.free_stringwas already bound instatsig_ffi.go, so nothing new is exported.2. The Go get callback returned an unterminated, possibly empty buffer
Rust reads this back with
CStr::from_ptr. Two problems, both fixed by appending one0byte:from_ptrruns past the end of the slice until it happens to find a zero. It survives today only because Go zeroes large fresh allocations.&result[0]on a zero-length slice, inside a purego callback. An adapter cannot signal "no data", even though the Rust side already handles that: a nilDataStoreResponse.resultbecomesDataStoreFailureand falls back to the network adapter.3. Background sync forwarded empty updates
start()rejects a nilresultwithDataStoreFailure, lettingStatsigCustomizedSpecsAdapterfall through to the network adapter.execute_background_sync_implhas no equivalent check, so a nil flows intosend_specs_update_to_listener, whereunwrap_or_default()makes it an empty body thatset_valuescan only fail to deserialize - once per tick, for as long as the store has nothing to serve.Nothing is corrupted: the failure happens during Prep, before the write lock, so loaded specs survive. But the sync is pure noise. Now it bails on a nil result and says so, matching
start().Testing
labmate's RSS should stop climbing and flatten near its ~360MB working set instead of sawtoothing to the cgroup limit.
After merging
Tag
statsig-go/v0.19.4-figma2- thefigma-releaseworkflow keys off that prefix and publishes the pairedbinaries-linux-gnu/v0.19.4-figma2with the rebuilt.so. The consuming pin is figma/figma#891051, already open and waiting on the tag.