Skip to content

fix(data-store): plug the FFI leak, NUL-terminate the Go get result, skip empty syncs - #30

Merged
vaidehi-figma merged 7 commits into
mainfrom
vduraphe/datastore-ffi-leak-and-nul
Aug 10, 2026
Merged

fix(data-store): plug the FFI leak, NUL-terminate the Go get result, skip empty syncs#30
vaidehi-figma merged 7 commits into
mainfrom
vduraphe/datastore-ffi-leak-and-nul

Conversation

@vaidehi-figma

@vaidehi-figma vaidehi-figma commented Aug 6, 2026

Copy link
Copy Markdown

Three fixes on the data-store path. All present on upstream main too - statsig-ffi/src/data_store_c.rs has not been touched since 2025-10-15.

1. The Set args were leaked (the reason for this PR)

string_to_c_char is CString::new(s).into_raw(), which transfers ownership to the callback. statsig-dotnet reclaims it in the finally block of GetNative, SetNative and SupportPollingNative. statsig-go never did, so all three callback args leaked on every call.

Set is the one that hurts: args_json is the full serialized specs - ~18MB for our project - and with a data store configured SpecStore writes 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 Set only runs when one is.

Fixed with a deferred free_string in each of the three Go callbacks, matching statsig-dotnet. free_string was already bound in statsig_ffi.go, so nothing new is exported.

An earlier revision of this PR freed in DataStoreC instead. Cursor correctly flagged that as a double-free against statsig-dotnet, which already frees the same pointer - thanks. Reverted; the callback owns the args, and Go is the binding that needed fixing.

2. The Go get callback returned an unterminated, possibly empty buffer

result := []byte(store.functions.Get(*keyStr))
return &result[0]

Rust reads this back with CStr::from_ptr. Two problems, both fixed by appending one 0 byte:

  • No terminator - from_ptr runs past the end of the slice until it happens to find a zero. It survives today only because Go zeroes large fresh allocations.
  • Panics on empty - &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 nil DataStoreResponse.result becomes DataStoreFailure and falls back to the network adapter.

3. Background sync forwarded empty updates

start() rejects a nil result with DataStoreFailure, letting StatsigCustomizedSpecsAdapter fall through to the network adapter. execute_background_sync_impl has no equivalent check, so a nil flows into send_specs_update_to_listener, where unwrap_or_default() makes it an empty body that set_values can 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 - the figma-release workflow keys off that prefix and publishes the paired binaries-linux-gnu/v0.19.4-figma2 with the rebuilt .so. The consuming pin is figma/figma#891051, already open and waiting on the tag.

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.
@vaidehi-figma
vaidehi-figma force-pushed the vduraphe/datastore-ffi-leak-and-nul branch from dfb8e7c to 21c06c8 Compare August 6, 2026 19:58
@vaidehi-figma
vaidehi-figma marked this pull request as ready for review August 6, 2026 20:04

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 21c06c8. Configure here.

Comment thread statsig-ffi/src/data_store_c.rs Outdated
…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.
@vaidehi-figma vaidehi-figma changed the title fix(data-store): free FFI strings and NUL-terminate the Go get result fix(data-store): plug the FFI leak, NUL-terminate the Go get result, skip empty syncs Aug 6, 2026
Comment thread statsig-go/data_store.go Outdated
Comment on lines 99 to 107

@cshi-figma cshi-figma Aug 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

yeah this makes sense, addressed

Comment thread statsig-go/data_store.go
store.functions.Shutdown,
// Get
func(argPtr *byte, argLength uint64) *byte {
// The core hands these args over via CString::into_raw, so the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claude is also saying that persistent_storage.go needs these same fixes, if we want to add that to a follow up?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

did it here instead of a follow-up! it's the same three bugs and the same review

cshi-figma
cshi-figma previously approved these changes Aug 7, 2026

@cshi-figma cshi-figma left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.
Comment thread statsig-go/data_store.go Outdated
// 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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]
              },

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

interestinggg! addressed (with help of claude). your suggested fix makes sense

vaidehi-figma and others added 3 commits August 10, 2026 18:48
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>
@vaidehi-figma
vaidehi-figma merged commit 28017b2 into main Aug 10, 2026
3 checks passed
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.

2 participants