Skip to content
Open
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
39 changes: 35 additions & 4 deletions bridge/core/arraybuffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,30 @@ import (
// For shared memory scenarios where modifications should be visible to both
// Go and JavaScript, use MapSharedBuffer instead.
func ToArrayBuffer(vm *sobek.Runtime, data []byte) sobek.Value {
return vm.ToValue(vm.NewArrayBuffer(data))
// @safety: explicitly copy data before vm.NewArrayBuffer because sobek does not copy
copiedData := make([]byte, len(data))
copy(copiedData, data)
return vm.ToValue(vm.NewArrayBuffer(copiedData))
}

// ToUint8Array converts a Go byte slice to a JavaScript Uint8Array.
// If Uint8Array is not available, it gracefully falls back to ArrayBuffer.
func ToUint8Array(vm *sobek.Runtime, data []byte) sobek.Value {
// @safety: explicitly copy data
copiedData := make([]byte, len(data))
copy(copiedData, data)
buf := vm.NewArrayBuffer(copiedData)
ctor := vm.Get("Uint8Array")
if ctor == nil || sobek.IsNull(ctor) || sobek.IsUndefined(ctor) {
return vm.ToValue(buf)
}

view := ctor.ToObject(vm)
typedArray, err := vm.New(view, vm.ToValue(buf))
if err != nil || typedArray == nil {
return vm.ToValue(buf)
}
return vm.ToValue(typedArray)
}

// MapSharedBuffer exposes a Go byte slice as a global JavaScript TypedArray.
Expand All @@ -30,7 +53,15 @@ func ToArrayBuffer(vm *sobek.Runtime, data []byte) sobek.Value {
// // In Go: data[0] == 42
func MapSharedBuffer(vm *sobek.Runtime, name string, data []byte) {
buf := vm.NewArrayBuffer(data)
view := vm.ToValue(vm.Get("Uint8Array")).ToObject(vm)
typedArray, _ := vm.New(view, vm.ToValue(buf))
_ = vm.GlobalObject().Set(name, typedArray)
ctor := vm.Get("Uint8Array")
if ctor == nil || sobek.IsNull(ctor) || sobek.IsUndefined(ctor) {
_ = vm.GlobalObject().Set(name, buf)
return
}

view := ctor.ToObject(vm)
typedArray, err := vm.New(view, vm.ToValue(buf))
if err == nil && typedArray != nil {
_ = vm.GlobalObject().Set(name, typedArray)
}
}
Comment on lines 54 to 67
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing fallback when Uint8Array construction fails.

If the Uint8Array constructor exists but vm.New() fails (line 63), the function silently exits without setting anything on the global object. This breaks the function's documented contract to expose the buffer.

The fallback path in ToUint8Array correctly returns the raw ArrayBuffer on failure; MapSharedBuffer should do the same.

🐛 Proposed fix to add fallback on construction failure
 	view := ctor.ToObject(vm)
 	typedArray, err := vm.New(view, vm.ToValue(buf))
 	if err == nil && typedArray != nil {
 		_ = vm.GlobalObject().Set(name, typedArray)
+	} else {
+		_ = vm.GlobalObject().Set(name, buf)
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge/core/arraybuffer.go` around lines 54 - 67, MapSharedBuffer currently
returns without setting the global when vm.New(view, vm.ToValue(buf)) errors;
change it so that on error (or nil typedArray) it falls back to exposing the raw
ArrayBuffer like ToUint8Array does — i.e., after calling vm.New(...) check err
and typedArray, and if err != nil or typedArray == nil call
vm.GlobalObject().Set(name, buf) (instead of silently returning) so the global
always gets either the created Uint8Array or the original ArrayBuffer.

8 changes: 4 additions & 4 deletions bridge/core/console.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ func (c *Console) Log(call sobek.FunctionCall) sobek.Value {
}

func (c *Console) Error(call sobek.FunctionCall) sobek.Value {
// @optimized: Use []interface{} and fmt.Println to avoid string conversion overhead and allocation.
args := make([]interface{}, len(call.Arguments))
// @optimized: Use []interface{} and a single fmt.Println to avoid double-locking os.Stdout.
args := make([]interface{}, len(call.Arguments)+1)
args[0] = "Error:"
for i, arg := range call.Arguments {
args[i] = arg.Export()
args[i+1] = arg.Export()
}
fmt.Print("Error: ")
fmt.Println(args...)
return sobek.Undefined()
}
Expand Down
34 changes: 29 additions & 5 deletions bridge/core/reflection.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package core
import (
"fmt"
"reflect"
"strconv"
"sync"

"github.com/grafana/sobek"
Expand Down Expand Up @@ -337,6 +338,20 @@ func wrapJSCallback(vm *sobek.Runtime, callable sobek.Callable, goType reflect.T
}

func bindSlice(vm *sobek.Runtime, v reflect.Value, visited map[uintptr]sobek.Value) (sobek.Value, error) {
// @optimized: Fast path for []byte using Uint8Array view
if v.Type().Elem() == reflect.TypeOf(byte(0)) {
var data []byte
if v.CanAddr() || v.Kind() == reflect.Slice {
data = v.Bytes()
} else {
// Unaddressable array, must copy to a slice first
data = make([]byte, v.Len())
reflect.Copy(reflect.ValueOf(data), v)
}
// Data MUST be copied inside ToUint8Array to prevent shared memory mutation
return ToUint8Array(vm, data), nil
}
Comment on lines +341 to +353
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

v.Bytes() will panic on byte arrays.

The reflect.Value.Bytes() method only works for slices—calling it on an array panics. The condition on line 344 incorrectly enters the v.Bytes() path for addressable byte arrays (e.g., [16]byte field in a struct).

🐛 Proposed fix to handle arrays correctly
 func bindSlice(vm *sobek.Runtime, v reflect.Value, visited map[uintptr]sobek.Value) (sobek.Value, error) {
 	// `@optimized`: Fast path for []byte using Uint8Array view
 	if v.Type().Elem() == reflect.TypeOf(byte(0)) {
 		var data []byte
-		if v.CanAddr() || v.Kind() == reflect.Slice {
+		if v.Kind() == reflect.Slice {
 			data = v.Bytes()
 		} else {
 			// Unaddressable array, must copy to a slice first
 			data = make([]byte, v.Len())
 			reflect.Copy(reflect.ValueOf(data), v)
 		}
 		// Data MUST be copied inside ToUint8Array to prevent shared memory mutation
 		return ToUint8Array(vm, data), nil
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bridge/core/reflection.go` around lines 341 - 353, The branch wrongly calls
v.Bytes() for addressable byte arrays which panics; change the fast-path to only
call v.Bytes() when v.Kind() == reflect.Slice (not just addressable), and handle
arrays (v.Kind() == reflect.Array) by copying into a new []byte before calling
ToUint8Array. Update the logic around the check using v.Type().Elem(), v.Kind(),
v.Bytes(), reflect.Copy and ToUint8Array so arrays always use reflect.Copy into
a make([]byte, v.Len()) buffer and only slices use v.Bytes().


// @optimized: Pre-allocate slice and use NewArray(vals...) to avoid repeated Set calls.
l := v.Len()
vals := make([]interface{}, l)
Expand All @@ -352,16 +367,25 @@ func bindSlice(vm *sobek.Runtime, v reflect.Value, visited map[uintptr]sobek.Val

func bindMap(vm *sobek.Runtime, v reflect.Value, visited map[uintptr]sobek.Value) (sobek.Value, error) {
obj := vm.NewObject()
for _, key := range v.MapKeys() {
// @optimized: Use MapRange to avoid allocating a slice of keys.
iter := v.MapRange()
for iter.Next() {
key := iter.Key()
var keyStr string
// @optimized: Avoid Sprintf if key is already a string.
if key.Kind() == reflect.String {

// @optimized: Use specific formatting functions to avoid interface boxing overhead.
switch key.Kind() {
case reflect.String:
keyStr = key.String()
} else {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
keyStr = strconv.FormatInt(key.Int(), 10)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
keyStr = strconv.FormatUint(key.Uint(), 10)
default:
keyStr = fmt.Sprint(key.Interface())
}

val, err := bindValue(vm, v.MapIndex(key), visited)
val, err := bindValue(vm, iter.Value(), visited)
if err != nil {
return nil, err
}
Expand Down
18 changes: 14 additions & 4 deletions eventloop/eventloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ type EventLoop struct {
VM *sobek.Runtime
jobQueue chan func()
stopChan chan struct{}
wg sync.WaitGroup
running bool

// @optimized: Group hot fields with their protecting mutex for better cache locality.
mu sync.Mutex
running bool
autoStop bool

wg sync.WaitGroup

ctx context.Context
cancel context.CancelFunc

Expand Down Expand Up @@ -124,17 +127,24 @@ func (el *EventLoop) CreatePromise() (promise *sobek.Object, resolve func(interf
// Keep the loop alive until the promise is settled
el.wg.Add(1)

// @safety: Use sync.Once to ensure wg.Done is decremented exactly once even if resolve/reject are called multiple times.
var once sync.Once

resolve = func(v interface{}) {
el.RunOnLoop(func() {
_ = res(v)
el.wg.Done()
once.Do(func() {
el.wg.Done()
})
})
}

reject = func(v interface{}) {
el.RunOnLoop(func() {
_ = rej(v)
el.wg.Done()
once.Do(func() {
el.wg.Done()
})
})
}

Expand Down
Loading