Skip to content

Commit 1d928b8

Browse files
committed
perf(bridge): true zero-copy binary responses via runtime.Pinner (issue #10)
BridgeCallBinary now returns a view into the pinned Go slice (runtime.Pinner + a registry id) instead of memcpy into a reusable C buffer; the NAPI shim wraps it with napi_create_external_buffer and releases the pin from the buffer finalizer (or immediately after the copy with TNB_ZERO_COPY=0). No copies on either side for binary blobs. First attempt (cgo.Handle) panicked on the runtime cgoCheckResult: cgo.Handle keeps the object alive but does not mark the pointer pinned for the cgo pointer rules. runtime.Pinner is the legal mechanism. Measured (this machine, lib.dom.d.ts 7.93MB blob): 40.4ms -> 37.7ms per first-fetch call (~7%); copy-vs-zero-copy A/B on the same build 44.5ms -> 37.7ms (~15%). Small blobs (<1MB) are noise-level. GC stability verified: content intact across forced GCs, no pin-registry growth over 100 fetches. Gates: sim-nav 37,409 units new=0, sim-xfile 71/71, sim-edit THROWS 0, f2hl/f2r6/emptyparity/refs-exportspec/display-tokens/computed-literal PASS, check:lib/enums/sourcefile-guard ok, volar suite 205/205.
1 parent c964489 commit 1d928b8

2 files changed

Lines changed: 98 additions & 40 deletions

File tree

‎patches/typescript-go/overlay/bridge/bridge.go‎

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
// - BridgeCall -> JSON envelope string {"ok":true,"data":...} | {"ok":false,"error":"..."}
1212
// - BridgeCallBinary -> raw bytes (for getSourceFile etc.), single-copy into a Node Buffer
1313
//
14-
// Memory model: both paths write into reusable C buffers. Calls are fully
15-
// synchronous, so the NAPI shim copies the data out to V8 before the next call
16-
// reuses the buffer. Zero per-call malloc, zero leaks.
14+
// Memory model: string envelopes reuse a process-lifetime C buffer (the NAPI
15+
// shim copies to V8 synchronously before the next call). Binary responses are
16+
// pinned Go slices (cgo.Handle) handed to V8 with zero copies — the pin is
17+
// released by BridgeReleaseBinary, immediately after a copy or from the
18+
// external buffer's finalizer on GC.
1719
package main
1820

1921
/*
@@ -22,18 +24,22 @@ package main
2224
#include <string.h>
2325
2426
// BridgeBinary is returned by value from BridgeCallBinary. Layout matches
25-
// napi_shim.c's declaration: { void* data; int64_t len; }.
26-
struct BridgeBinary { void* data; long long len; };
27+
// napi_shim.c's declaration: { void* data; int64_t len; uint64_t handle; }.
28+
// data is a view into the pinned Go slice; handle releases the pin via
29+
// BridgeReleaseBinary.
30+
struct BridgeBinary { void* data; long long len; unsigned long long handle; };
2731
*/
2832
import "C"
2933

3034
import (
3135
"context"
3236
"encoding/base64"
3337
"os"
38+
"runtime"
3439
"strconv"
3540
"strings"
3641
"sync"
42+
"sync/atomic"
3743
"time"
3844
"unsafe"
3945

@@ -54,11 +60,6 @@ var (
5460
// resultBuf is a single reusable C buffer for JSON envelope responses.
5561
resultBuf *C.char
5662
resultBufLen C.size_t
57-
58-
// binBuf is a single reusable C buffer for binary responses (getSourceFile).
59-
// Grows only; never freed (one buffer for the process lifetime).
60-
binBuf unsafe.Pointer
61-
binBufLen C.size_t
6263
)
6364

6465
type sessionEntry struct {
@@ -100,17 +101,6 @@ func returnEnvelope(env envelope) *C.char {
100101
return resultBuf
101102
}
102103

103-
// ensureBinBuf grows the reusable binary buffer to at least need bytes.
104-
func ensureBinBuf(need C.size_t) {
105-
if binBuf == nil || binBufLen < need {
106-
if binBuf != nil {
107-
C.free(binBuf)
108-
}
109-
binBuf = C.malloc(need)
110-
binBufLen = need
111-
}
112-
}
113-
114104
// bridgeCheckerPoolOptions returns checker pool sizing for the in-process bridge.
115105
// Default MaxCheckers=5 (1 diagnostics + 4 query) aligns with tsgo CLI parallelism.
116106
// Override with TSGO_CHECKERS (minimum 2).
@@ -281,13 +271,17 @@ func BridgeCall(session C.int64_t, method *C.char, paramsJson *C.char) *C.char {
281271
// api.RawBinary which is handed straight through with no base64. For non-binary
282272
// methods, the JSON-marshaled bytes are returned (caller can JSON.parse).
283273
//
284-
// Returns a struct by value: { void* data; int64_t len }. data points into the
285-
// reusable binary buffer and stays valid until the next bridge call. len is 0
286-
// for nil/empty results.
274+
// Returns a struct by value: { void* data; int64_t len; uint64_t handle }.
275+
// data points INTO the Go slice (no copy on this side); the slice is pinned via
276+
// cgo.NewHandle so Go's (non-moving) GC keeps it valid. Ownership of the pin
277+
// transfers to the caller (napi_shim.c), which must call BridgeReleaseBinary
278+
// exactly once — immediately after copying, or from the external buffer's
279+
// finalizer on GC in the zero-copy path. len is 0 for nil/empty results
280+
// (handle is 0 then; nothing to release).
287281
//
288282
//export BridgeCallBinary
289283
func BridgeCallBinary(session C.int64_t, method *C.char, paramsJson *C.char) C.struct_BridgeBinary {
290-
empty := C.struct_BridgeBinary{data: nil, len: 0}
284+
empty := C.struct_BridgeBinary{data: nil, len: 0, handle: 0}
291285

292286
mu.Lock()
293287
entry, ok := sessions[int64(session)]
@@ -308,14 +302,12 @@ func BridgeCallBinary(session C.int64_t, method *C.char, paramsJson *C.char) C.s
308302
res, err := entry.api.HandleRequest(context.Background(), methodStr, params)
309303
if err != nil {
310304
// No way to surface an error through the binary path without a side
311-
// channel; fall back to a JSON error envelope written into the binary
312-
// buffer so the caller can detect it by parsing. The contract is:
313-
// callers use BridgeCallBinary only for methods known to return binary.
305+
// channel; fall back to a JSON error envelope so the caller can detect
306+
// it by parsing. The contract is: callers use BridgeCallBinary only for
307+
// methods known to return binary.
314308
errEnv := envelope{OK: false, Error: err.Error()}
315309
b, _ := json.Marshal(errEnv)
316-
ensureBinBuf(C.size_t(len(b)))
317-
C.memcpy(binBuf, unsafe.Pointer(&b[0]), C.size_t(len(b)))
318-
return C.struct_BridgeBinary{data: binBuf, len: C.longlong(len(b))}
310+
return pinBinary(b)
319311
}
320312

321313
var bytes []byte
@@ -334,9 +326,39 @@ func BridgeCallBinary(session C.int64_t, method *C.char, paramsJson *C.char) C.s
334326
if len(bytes) == 0 {
335327
return empty
336328
}
337-
ensureBinBuf(C.size_t(len(bytes)))
338-
C.memcpy(binBuf, unsafe.Pointer(&bytes[0]), C.size_t(len(bytes)))
339-
return C.struct_BridgeBinary{data: binBuf, len: C.longlong(len(bytes))}
329+
return pinBinary(bytes)
330+
}
331+
332+
// pinBinary pins b via runtime.Pinner (the cgo-legal way to return a Go heap
333+
// pointer: cgo.Handle would keep the object alive but does NOT mark the
334+
// pointer pinned, and the runtime's cgoCheckResult panics on it) and returns
335+
// the view + a registry id for BridgeReleaseBinary.
336+
var (
337+
binPinnerNext uint64
338+
binPinners sync.Map // uint64 -> *runtime.Pinner
339+
)
340+
341+
func pinBinary(b []byte) C.struct_BridgeBinary {
342+
p := &runtime.Pinner{}
343+
p.Pin(&b[0])
344+
id := atomic.AddUint64(&binPinnerNext, 1)
345+
binPinners.Store(id, p)
346+
return C.struct_BridgeBinary{
347+
data: unsafe.Pointer(&b[0]),
348+
len: C.longlong(len(b)),
349+
handle: C.uint64_t(id),
350+
}
351+
}
352+
353+
// BridgeReleaseBinary releases a pin from pinBinary. Called exactly once per
354+
// BridgeCallBinary result: immediately after the caller copies the bytes, or
355+
// from the external buffer's finalizer when V8 is done with the zero-copy view.
356+
//
357+
//export BridgeReleaseBinary
358+
func BridgeReleaseBinary(handle C.uint64_t) {
359+
if p, ok := binPinners.LoadAndDelete(uint64(handle)); ok {
360+
p.(*runtime.Pinner).Unpin()
361+
}
340362
}
341363

342364
// BridgeDisposeSession closes the session and releases all refs.

‎patches/typescript-go/overlay/bridge/napi_shim.c‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
// - String results: Go-owned reusable buffer (resultBuf in bridge.go),
1111
// copied into V8 by napi_create_string_utf8. Nothing to free — Go keeps
1212
// ownership and recycles it on the next call.
13-
// - Binary results: Go-owned reusable buffer (binBuf), copied by
14-
// napi_create_buffer_copy. Same ownership rule.
13+
// - Binary results: Go pins the slice via cgo.Handle and hands V8 a view —
14+
// zero copies. The pin is released by BridgeReleaseBinary, either from the
15+
// external buffer's finalizer on GC (default) or right after
16+
// napi_create_buffer_copy (TNB_ZERO_COPY=0 fallback). Go's GC is
17+
// non-moving, so the view stays valid until release.
1518
// - Everything synchronous on the JS thread. No async NAPI, no callbacks,
1619
// no thread-safe functions.
1720

@@ -20,11 +23,12 @@
2023
#include <stdlib.h>
2124

2225
// cgo-exported Go entry points (bridge.go).
23-
struct BridgeBinary { void* data; long long len; };
26+
struct BridgeBinary { void* data; long long len; unsigned long long handle; };
2427
extern char* BridgeNewSession(char* cwd);
2528
extern char* BridgeCall(int64_t session, char* method, char* paramsJson);
2629
extern struct BridgeBinary BridgeCallBinary(int64_t session, char* method, char* paramsJson);
2730
extern void BridgeDisposeSession(int64_t session);
31+
extern void BridgeReleaseBinary(unsigned long long handle);
2832

2933
// Required string argument → fresh NUL-terminated buffer (caller frees).
3034
// Returns NULL with nothing set if conversion fails (napi raises then).
@@ -120,6 +124,26 @@ static napi_value fn_call(napi_env env, napi_callback_info info) {
120124
return js_string(env, out);
121125
}
122126

127+
// Zero-copy control: TNB_ZERO_COPY=0 forces the copy path. Default hands V8 a
128+
// view of the pinned Go slice (no copies anywhere); the pin releases via
129+
// BridgeReleaseBinary — from the external buffer's finalizer on GC, or right
130+
// after napi_create_buffer_copy in the fallback. Read once; env is fixed
131+
// before require().
132+
static int zero_copy_enabled(void) {
133+
static int cached = -1;
134+
if (cached < 0) {
135+
const char* v = getenv("TNB_ZERO_COPY");
136+
cached = (v && v[0] == '0' && v[1] == '\0') ? 0 : 1;
137+
}
138+
return cached;
139+
}
140+
141+
static void finalize_release(napi_env env, void* data, void* hint) {
142+
(void)env;
143+
(void)data;
144+
BridgeReleaseBinary((unsigned long long)(uintptr_t)hint);
145+
}
146+
123147
// callBinary(session, method, paramsJson | null): Buffer | null
124148
static napi_value fn_call_binary(napi_env env, napi_callback_info info) {
125149
int64_t session;
@@ -135,9 +159,21 @@ static napi_value fn_call_binary(napi_env env, napi_callback_info info) {
135159
return nul;
136160
}
137161
napi_value buf = NULL;
138-
void* dst = NULL;
139-
// V8 copies the bytes; Go keeps the source (reusable binBuf).
140-
napi_create_buffer_copy(env, (size_t)res.len, res.data, &dst, &buf);
162+
if (zero_copy_enabled()) {
163+
// True zero-copy: V8 views the pinned Go slice directly; the finalizer
164+
// releases the pin (GC-safe — Go's GC is non-moving and the slice stays
165+
// alive until BridgeReleaseBinary runs).
166+
if (napi_create_external_buffer(env, (size_t)res.len, res.data, finalize_release, (void*)(uintptr_t)res.handle, &buf) != napi_ok) {
167+
void* dst = NULL;
168+
napi_create_buffer_copy(env, (size_t)res.len, res.data, &dst, &buf);
169+
BridgeReleaseBinary(res.handle);
170+
}
171+
}
172+
else {
173+
void* dst = NULL;
174+
napi_create_buffer_copy(env, (size_t)res.len, res.data, &dst, &buf);
175+
BridgeReleaseBinary(res.handle);
176+
}
141177
return buf;
142178
}
143179

0 commit comments

Comments
 (0)