Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions statsig-go/data_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ func NewDataStore(functions DataStoreFunctions) *DataStore {
ref: 0,
}

store.ref = GetFFI().data_store_create(
ffi := GetFFI()
ffi.mu.Lock()
store.ref = ffi.data_store_create(
store.functions.Initialize,
store.functions.Shutdown,
// Get
Expand Down Expand Up @@ -63,16 +65,24 @@ func NewDataStore(functions DataStoreFunctions) *DataStore {
return store.functions.ShouldBeUsedForQueryingUpdates(*keyStr)
},
)
ffi.mu.Unlock()

runtime.SetFinalizer(store, func(obj *DataStore) {
GetFFI().data_store_release(obj.ref)
ffi := GetFFI()
ffi.mu.Lock()
ffi.data_store_release(obj.ref)
ffi.mu.Unlock()
})

return store
}

func (d *DataStore) INTERNAL_testDataStore(path string, value string) string {
return GetFFI().__internal__test_data_store(d.ref, path, value)
ffi := GetFFI()
ffi.mu.Lock()
r := ffi.__internal__test_data_store(d.ref, path, value)
ffi.mu.Unlock()
return r
}

type dataStoreSetArgs struct {
Expand Down
15 changes: 12 additions & 3 deletions statsig-go/observability_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ func NewObservabilityClient(functions ObservabilityClientFunctions) *Observabili
ref: 0,
}

client.ref = GetFFI().observability_client_create(
ffi := GetFFI()
ffi.mu.Lock()
client.ref = ffi.observability_client_create(
client.functions.Init,
// Increment
func(argsPtr *byte, argsLength uint64) {
Expand Down Expand Up @@ -84,16 +86,23 @@ func NewObservabilityClient(functions ObservabilityClientFunctions) *Observabili
return client.functions.ShouldEnableHighCardinalityForThisTag(*tag)
},
)
ffi.mu.Unlock()

runtime.SetFinalizer(client, func(obj *ObservabilityClient) {
GetFFI().observability_client_release(obj.ref)
ffi := GetFFI()
ffi.mu.Lock()
ffi.observability_client_release(obj.ref)
ffi.mu.Unlock()
})

return client
}

func (c *ObservabilityClient) INTERNAL_testObservabilityClient(action string, metricName string, value float64, tags string) {
GetFFI().__internal__test_observability_client(c.ref, action, metricName, value, tags)
ffi := GetFFI()
ffi.mu.Lock()
ffi.__internal__test_observability_client(c.ref, action, metricName, value, tags)
ffi.mu.Unlock()
}

func tryMarshalStandardArgs(inputPtr *byte, inputLength uint64) (*obsClientArgs, error) {
Expand Down
16 changes: 13 additions & 3 deletions statsig-go/persistent_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ func NewPersistentStorage(functions PersistentStorageFunctions) *PersistentStora
ref: 0,
}

storage.ref = GetFFI().persistent_storage_create(
ffi := GetFFI()
ffi.mu.Lock()
storage.ref = ffi.persistent_storage_create(
"go",
// Load
func(argsPtr *byte, argsLength uint64) *byte {
Expand Down Expand Up @@ -100,16 +102,24 @@ func NewPersistentStorage(functions PersistentStorageFunctions) *PersistentStora
storage.functions.Delete(data.Key, data.ConfigName)
},
)
ffi.mu.Unlock()

runtime.SetFinalizer(storage, func(obj *PersistentStorage) {
GetFFI().persistent_storage_release(obj.ref)
ffi := GetFFI()
ffi.mu.Lock()
ffi.persistent_storage_release(obj.ref)
ffi.mu.Unlock()
})

return storage
}

func (c *PersistentStorage) INTERNAL_testPersistentStorage(action string, key string, configName string, data string) string {
return GetFFI().__internal__test_persistent_storage(c.ref, action, key, configName, data)
ffi := GetFFI()
ffi.mu.Lock()
r := ffi.__internal__test_persistent_storage(c.ref, action, key, configName, data)
ffi.mu.Unlock()
return r
}

func tryMarshalPersistentStorageArgs(inputPtr *byte, inputLength uint64) (*persistentStorageArgs, error) {
Expand Down
64 changes: 49 additions & 15 deletions statsig-go/statsig.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ type Statsig struct {
}

func NewStatsig(sdkKey string) (*Statsig, error) {
ref := GetFFI().statsig_create(sdkKey, 0)
ffi := GetFFI()
ffi.mu.Lock()
ref := ffi.statsig_create(sdkKey, 0)
ffi.mu.Unlock()
if ref == 0 {
return nil, fmt.Errorf("error creating Statsig instance")
}
Expand All @@ -38,7 +41,10 @@ func NewStatsig(sdkKey string) (*Statsig, error) {
}

func NewStatsigWithOptions(sdkKey string, opts *StatsigOptions) (*Statsig, error) {
ref := GetFFI().statsig_create(sdkKey, opts.ref)
ffi := GetFFI()
ffi.mu.Lock()
ref := ffi.statsig_create(sdkKey, opts.ref)
ffi.mu.Unlock()
if ref == 0 {
return nil, fmt.Errorf("error creating Statsig instance")
}
Expand Down Expand Up @@ -66,15 +72,24 @@ func (s *Statsig) NewUserBuilderWithCustomIDs(customIDs map[string]any) *Statsig
}

func (s *Statsig) Initialize() {
GetFFI().statsig_initialize_blocking(s.ref.Load())
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_initialize_blocking(s.ref.Load())
ffi.mu.Unlock()
}

func (s *Statsig) Shutdown() {
GetFFI().statsig_shutdown_blocking(s.ref.Load())
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_shutdown_blocking(s.ref.Load())
ffi.mu.Unlock()
}

func (s *Statsig) FlushEvents() {
GetFFI().statsig_flush_events_blocking(s.ref.Load())
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_flush_events_blocking(s.ref.Load())
ffi.mu.Unlock()
}

func (s *Statsig) LogEvent(user *StatsigUser, event EventPayload) {
Expand All @@ -83,8 +98,10 @@ func (s *Statsig) LogEvent(user *StatsigUser, event EventPayload) {
log.Printf("Failed to marshal Statsig event: %v", err)
return
}

GetFFI().statsig_log_event(s.ref.Load(), user.ref, string(eventJson))
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_log_event(s.ref.Load(), user.ref, string(eventJson))
ffi.mu.Unlock()
}

func (s *Statsig) CheckGate(user *StatsigUser, gateName string) bool {
Expand All @@ -97,8 +114,11 @@ func (s *Statsig) CheckGateWithOptions(user *StatsigUser, gateName string, optio
fmt.Printf("Failed to marshal FeatureGateEvaluationOptions: %v", err)
return false
}

return GetFFI().statsig_check_gate(s.ref.Load(), user.ref, gateName, optionsJson)
ffi := GetFFI()
ffi.mu.Lock()
result := ffi.statsig_check_gate(s.ref.Load(), user.ref, gateName, optionsJson)
ffi.mu.Unlock()
return result
}

func (s *Statsig) GetFeatureGate(user *StatsigUser, gateName string) FeatureGate {
Expand Down Expand Up @@ -300,28 +320,42 @@ func (s *Statsig) GetClientInitResponseWithOptions(user *StatsigUser, options *C
}

func (s *Statsig) ManuallyLogFeatureGateExposure(user *StatsigUser, gateName string) {
GetFFI().statsig_manually_log_gate_exposure(s.ref.Load(), user.ref, gateName)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_manually_log_gate_exposure(s.ref.Load(), user.ref, gateName)
ffi.mu.Unlock()
}

func (s *Statsig) ManuallyLogDynamicConfigExposure(user *StatsigUser, configName string) {
GetFFI().statsig_manually_log_dynamic_config_exposure(s.ref.Load(), user.ref, configName)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_manually_log_dynamic_config_exposure(s.ref.Load(), user.ref, configName)
ffi.mu.Unlock()
}

func (s *Statsig) ManuallyLogExperimentExposure(user *StatsigUser, experimentName string) {
GetFFI().statsig_manually_log_experiment_exposure(s.ref.Load(), user.ref, experimentName)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_manually_log_experiment_exposure(s.ref.Load(), user.ref, experimentName)
ffi.mu.Unlock()
}

func (s *Statsig) ManuallyLogLayerParamExposure(user *StatsigUser, layerName string, paramName string) {
GetFFI().statsig_manually_log_layer_parameter_exposure(s.ref.Load(), user.ref, layerName, paramName)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_manually_log_layer_parameter_exposure(s.ref.Load(), user.ref, layerName, paramName)
ffi.mu.Unlock()
}

func (s *Statsig) release() {
was := s.ref.Swap(0)
if was == 0 {
return
}

GetFFI().statsig_release(was)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_release(was)
ffi.mu.Unlock()
}

func tryMarshalOrEmpty[T any](data *T) (string, error) {
Expand Down
49 changes: 46 additions & 3 deletions statsig-go/statsig_ffi.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ import (
type StatsigFFI struct {
lib uintptr

// mu serializes every purego-dispatched call from this binding.
//
// Why: purego's reflect-based call wrapper (`func.go`) shares a
// process-wide `sync.Pool` of `*syscall15Args` across concurrent
// dispatches. When two goroutines are inside that wrapper at the
// same time, the return value of one call can land in the other
// goroutine's read of `syscall.a1` — i.e. two FFI calls' return
// pointers get swapped between callers.
//
// Symptoms downstream:
// - SIGSEGV in runtime.memmove on a non-canonical pointer (a
// `*c_char` from a different call than the one Go thinks it's
// reading)
// - SIGSEGV nil-deref at the `*gateJson` deref in
// GetFeatureGateWithOptions
// - glibc `double free or corruption (out)` when two callers
// both try to free the same buffer
// - Silently-wrong gate / config / experiment results — the FFI
// for one call returns data shaped like a sibling call's
//
// Repro and discrimination matrix:
// github.com/figma/statsig-server-core/... (TODO: tracking issue)
// Upstream: ebitengine/purego — TODO file issue, link here.
//
// Lock scope: held only across the C-side dispatch (and any
// surrounding bookkeeping that hits FFI, e.g. `free_string`).
// JSON marshal/unmarshal on the Go side is intentionally outside
// the critical section. Per-call hold time is microseconds on the
// hot path.
//
// Lock-ordering: callbacks registered via `RegisterFunc` (data
// store, observability client, persistent storage) MUST NOT take
// `mu` if there is any chance the Rust side invokes them
// synchronously from inside an FFI call this binding has dispatched
// (would self-deadlock).
mu sync.Mutex

// StatsigOptions
statsig_options_create_from_data func(string) uint64
statsig_options_release func(uint64)
Expand Down Expand Up @@ -223,13 +260,19 @@ func GetFFI() *StatsigFFI {
}

func UseRustString(handler func() (*byte, uint64)) *string {
// Hold the FFI mutex across both the C-string-returning dispatch
// (inside handler) AND the matching free_string. See the comment
// on StatsigFFI.mu for why this is required.
instance.mu.Lock()
defer instance.mu.Unlock()

ptr, length := handler()
if ptr == nil {
return nil
}

defer instance.free_string(ptr)
return internal.GoStringFromPointer(ptr, length)
s := internal.GoStringFromPointer(ptr, length)
instance.free_string(ptr)
return s
}

func (ffi *StatsigFFI) updateStatsigMetadata() {
Expand Down
12 changes: 8 additions & 4 deletions statsig-go/statsig_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ func (o *StatsigOptionsBuilder) Build() (*StatsigOptions, error) {
return nil, err
}

ref := GetFFI().statsig_options_create_from_data(
string(data),
)
ffi := GetFFI()
ffi.mu.Lock()
ref := ffi.statsig_options_create_from_data(string(data))
ffi.mu.Unlock()

if ref == 0 {
return nil, fmt.Errorf("failed to create StatsigOptions")
Expand All @@ -169,7 +170,10 @@ func (o *StatsigOptionsBuilder) Build() (*StatsigOptions, error) {
}

runtime.SetFinalizer(options, func(obj *StatsigOptions) {
GetFFI().statsig_options_release(obj.ref)
ffi := GetFFI()
ffi.mu.Lock()
ffi.statsig_options_release(obj.ref)
ffi.mu.Unlock()
})

return options, nil
Expand Down
Loading