Skip to content

perf(core): audit optimizations for reflection, eventloop, and memory handling#154

Open
repyh wants to merge 1 commit into
mainfrom
optimization/reflection-eventloop-fixes-11718481340588092711
Open

perf(core): audit optimizations for reflection, eventloop, and memory handling#154
repyh wants to merge 1 commit into
mainfrom
optimization/reflection-eventloop-fixes-11718481340588092711

Conversation

@repyh
Copy link
Copy Markdown
Owner

@repyh repyh commented May 15, 2026

This PR encapsulates the results of the daily performance and optimization audit for the TypeGo core bridge module. It systematically zeroes in on avoiding reflection/interface overhead and guarantees memory and synchronization safety.

Technical highlights:

  • bridge/core/reflection.go: Replaced allocation-heavy map key evaluation (MapKeys) with iterators (MapRange). Streamlined primitive formatting. Explicitly handled byte-slice conversion to direct memory bridging.
  • eventloop/eventloop.go: Resolved a structural defect via sync.Once wrapper inside promises, preventing multithreaded double-resolve panics.
  • bridge/core/arraybuffer.go: Patched ToArrayBuffer to force deep-copy behavior for strict engine isolation, mitigating memory stomps. Handled global context degradation gracefully by providing native struct fallback if Uint8Array constructor vanishes in sandbox.

All core benchmarks pass correctly, code has been audited via manual benchmarks, and safety annotations have been provided as requested.


PR created automatically by Jules for task 11718481340588092711 started by @repyh

Summary by CodeRabbit

  • Bug Fixes

    • Fixed memory safety issue preventing JavaScript from modifying shared Go data.
    • Resolved promise settling multiple times from single resolver.
    • Added fallback support for environments lacking certain JavaScript APIs.
  • Performance

    • Optimized JavaScript binding of Go values.
  • Tests

    • Added performance benchmarks for promise creation.

Review Change Stack

… handling

- **Reflection Optimization**: Reduced `interface{}` boxing in `bindMap` by replacing `v.MapKeys()` with `v.MapRange()` and avoiding generic `fmt.Sprint` for primitive integer keys.
- **ArrayBuffer Boxing**: Modified `bindSlice` to skip interface element-wrapping for `[]byte`, mapping directly to a JS `Uint8Array`. Handled unaddressable array copying explicitly.
- **Memory Safety**: `ToArrayBuffer` now explicitly copies slices instead of directly wrapping memory, solving a potentially lethal shared-memory vulnerability between JS and Go.
- **EventLoop Deadlock Fix**: Restored `sync.Once` logic inside `CreatePromise` to guarantee `wg.Done()` is only dispatched once, averting `negative WaitGroup counter` panics on double resolve/reject sequences.
- Added comprehensive documentation and explicit fallback logic for globals like `Uint8Array` in `MapSharedBuffer`.

Co-authored-by: repyh <63894915+repyh@users.noreply.github.com>
@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 15, 2026

📝 Walkthrough

Walkthrough

This PR hardens the Go-JavaScript bridge by adding memory-safe ArrayBuffer copying, providing TypedArray fallback logic, optimizing value binding via strconv, and making promise settlement idempotent with sync.Once guards. Four files are modified across three related safety improvements.

Changes

Go-JS Bridge Memory Safety and Promise Handling

Layer / File(s) Summary
ArrayBuffer memory safety and TypedArray fallback
bridge/core/arraybuffer.go
ToArrayBuffer copies input data before creating ArrayBuffer to prevent shared memory mutations. MapSharedBuffer checks Uint8Array availability and falls back to raw ArrayBuffer if unavailable. New ToUint8Array helper encapsulates byte-slice-to-typed-array conversion with automatic fallback.
Reflection binding optimizations with typed array and map improvements
bridge/core/reflection.go
bindSlice detects byte slices and delegates to ToUint8Array, including unaddressable value handling via element-by-element copy. bindMap replaces MapKeys() iteration with MapRange() and optimizes key stringification using strconv.FormatInt/FormatUint for integer keys and fmt.Sprint fallback.
Promise creation settlement idempotency and validation
eventloop/eventloop.go, eventloop/eventloop_bench_test.go
CreatePromise wraps resolve and reject callbacks with sync.Once to guarantee single settlement and prevent duplicate wait-group completions. BenchmarkCreatePromise exercises repeated resolution attempts and validates non-blocking behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A bridge now guards its bytes with care,
Arrays copy, never share,
Once a promise settles down,
No double-settling 'round the town!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the main changes: performance optimizations focused on reflection, eventloop, and memory handling across the core module.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch optimization/reflection-eventloop-fixes-11718481340588092711

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
bridge/core/arraybuffer.go (1)

20-51: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Doc comment becomes inaccurate in the fallback path.

The function-level doc (Lines 24–26) promises the global is "accessible as a Uint8Array in JavaScript", but the new fallback at Lines 39–43 / 46–49 exposes a raw ArrayBuffer when Uint8Array is unavailable or construction fails. Worth a one-line clarification so callers don't assume globalThis[name].byteLength-style typed-array semantics in degraded runtimes.

📝 Proposed doc tweak
 // The buffer is registered as a global variable with the given name, accessible
-// as a Uint8Array in JavaScript. This is commonly used for inter-worker
-// communication and zero-copy data sharing.
+// as a Uint8Array in JavaScript when the Uint8Array constructor is available;
+// otherwise it falls back to exposing the raw ArrayBuffer. This is commonly
+// used for inter-worker communication and zero-copy data sharing.

Side note: in that fallback path, downstream JS code has no way to read/write individual bytes without a typed-array constructor, so the "shared memory" contract is effectively reduced to handing JS an opaque buffer. That's fine as a crash-avoidance measure, but if a stricter behavior (e.g., error / log) is preferred, consider surfacing the degraded mode.

🤖 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 20 - 51, The function doc for
MapSharedBuffer is inaccurate in degraded runtimes: update the comment to state
that while normally the global is exposed as a Uint8Array, if the runtime lacks
Uint8Array (checked via vm.Get("Uint8Array")) or vm.New(ctor.ToObject(vm), ...)
fails the function exposes the raw ArrayBuffer created by vm.NewArrayBuffer;
explicitly note that in this fallback the global is an ArrayBuffer (opaque to
per-byte reads/writes without a typed-array) so callers should not assume
Uint8Array semantics.
🧹 Nitpick comments (1)
bridge/core/reflection.go (1)

340-355: ⚡ Quick win

Verify downstream impact: []byte now binds as Uint8Array instead of Array.

This is a deliberate semantic change: downstream JS code expecting array methods may break:

  • Array.isArray(v) — returns false for Uint8Array.
  • JSON.stringify(v) — produces {"0":1,"1":2,...} instead of [1,2,...].
  • .map() / .filter() — return another Uint8Array, not Array.

Confirm that existing tests and any JS bindings handling bound Go structs with []byte fields still pass. (Current test coverage doesn't exercise this path explicitly.)

Optional: simplify unaddressable-array branch with reflect.Copy.

The manual element-by-element loop at lines 347–352 can be replaced with reflect.Copy, which is idiomatic and avoids per-element Uint() conversion:

♻️ Optional cleanup using reflect.Copy
 		var data []byte
 		if v.CanAddr() || v.Kind() == reflect.Slice {
 			data = v.Bytes()
 		} else {
-			// Unaddressable array, we must copy it element by element or create a slice copy
-			l := v.Len()
-			data = make([]byte, l)
-			for i := 0; i < l; i++ {
-				data[i] = byte(v.Index(i).Uint())
-			}
+			// Unaddressable array: copy into an addressable []byte.
+			data = make([]byte, v.Len())
+			reflect.Copy(reflect.ValueOf(data), v)
 		}
 		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 340 - 355, The change in bindSlice
causes []byte to bind as a Uint8Array (via ToUint8Array) which is a semantic
change from Array and can break downstream JS (Array.isArray, JSON.stringify,
Array methods); either revert to returning a JS Array for []byte or add a clear
compatibility path/flag and update tests to assert expected behavior for
Array.isArray(JSON.stringify(...), map/filter results) when binding []byte
(check bindSlice and ToUint8Array usages). Also, replace the manual element-copy
loop in bindSlice's unaddressable-array branch with reflect.Copy by creating a
new slice via reflect.MakeSlice, reflect.Copy into it, then convert to []byte to
pass to ToUint8Array for correctness and simplicity.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@eventloop/eventloop_bench_test.go`:
- Around line 11-20: The benchmark must disable the event loop's auto-stop and
fully drain pending jobs in cleanup: create the loop with autoStop disabled
(e.g. pass the WithAutoStop(false) option to NewEventLoop or call
el.SetAutoStop(false) before Start()), then start it (el.Start()), run the
CreatePromise/resolve loop, and on teardown call el.Stop() and then drain/wait
for the loop to process remaining jobs (e.g. el.Drain() or el.WaitForEmpty()) so
resolve() never writes into an unserviced jobQueue; update references around
NewEventLoop, el.Start, CreatePromise, resolve, el.Stop and the loop drain API
in the file.

---

Outside diff comments:
In `@bridge/core/arraybuffer.go`:
- Around line 20-51: The function doc for MapSharedBuffer is inaccurate in
degraded runtimes: update the comment to state that while normally the global is
exposed as a Uint8Array, if the runtime lacks Uint8Array (checked via
vm.Get("Uint8Array")) or vm.New(ctor.ToObject(vm), ...) fails the function
exposes the raw ArrayBuffer created by vm.NewArrayBuffer; explicitly note that
in this fallback the global is an ArrayBuffer (opaque to per-byte reads/writes
without a typed-array) so callers should not assume Uint8Array semantics.

---

Nitpick comments:
In `@bridge/core/reflection.go`:
- Around line 340-355: The change in bindSlice causes []byte to bind as a
Uint8Array (via ToUint8Array) which is a semantic change from Array and can
break downstream JS (Array.isArray, JSON.stringify, Array methods); either
revert to returning a JS Array for []byte or add a clear compatibility path/flag
and update tests to assert expected behavior for
Array.isArray(JSON.stringify(...), map/filter results) when binding []byte
(check bindSlice and ToUint8Array usages). Also, replace the manual element-copy
loop in bindSlice's unaddressable-array branch with reflect.Copy by creating a
new slice via reflect.MakeSlice, reflect.Copy into it, then convert to []byte to
pass to ToUint8Array for correctness and simplicity.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 59d3e334-0585-4bc7-8bce-fb245dcf7210

📥 Commits

Reviewing files that changed from the base of the PR and between b85f440 and 740be7d.

📒 Files selected for processing (4)
  • bridge/core/arraybuffer.go
  • bridge/core/reflection.go
  • eventloop/eventloop.go
  • eventloop/eventloop_bench_test.go

Comment on lines +11 to +20
el := eventloop.NewEventLoop(vm)
go el.Start()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, resolve, _ := el.CreatePromise()
resolve(nil)
resolve(nil) // should not panic/deadlock now
}
el.Stop()
}
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

Disable auto-stop and drain the loop in cleanup.

With the default autoStop=true, go el.Start() can stop before the first CreatePromise() increments the wait group. After that, resolve() pushes into an unserviced jobQueue, so this benchmark can hang once the buffer fills and may exit before the last settlements run.

Proposed fix
 import (
+	"context"
 	"testing"
-	"github.com/repyh/typego/eventloop"
 	"github.com/grafana/sobek"
+	"github.com/repyh/typego/eventloop"
+	"time"
 )
 
 func BenchmarkCreatePromise(b *testing.B) {
 	vm := sobek.New()
 	el := eventloop.NewEventLoop(vm)
+	el.SetAutoStop(false)
 	go el.Start()
+	b.Cleanup(func() {
+		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+		defer cancel()
+		_ = el.Shutdown(ctx)
+	})
 	b.ResetTimer()
 	for i := 0; i < b.N; i++ {
 		_, resolve, _ := el.CreatePromise()
 		resolve(nil)
 		resolve(nil) // should not panic/deadlock now
 	}
-	el.Stop()
 }
🤖 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 `@eventloop/eventloop_bench_test.go` around lines 11 - 20, The benchmark must
disable the event loop's auto-stop and fully drain pending jobs in cleanup:
create the loop with autoStop disabled (e.g. pass the WithAutoStop(false) option
to NewEventLoop or call el.SetAutoStop(false) before Start()), then start it
(el.Start()), run the CreatePromise/resolve loop, and on teardown call el.Stop()
and then drain/wait for the loop to process remaining jobs (e.g. el.Drain() or
el.WaitForEmpty()) so resolve() never writes into an unserviced jobQueue; update
references around NewEventLoop, el.Start, CreatePromise, resolve, el.Stop and
the loop drain API in the file.

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.

1 participant