feat(bridge_capi): port fivenode_capi.c / fivenode_buffers.c to Go RTL

Seven HB_FUNCs that fivenode's bridge_*.prg layer relies on:
  _CTX_SET_JSON / _CTX_GET_JSON  — per-request context payload
  _OUT_APPEND   / _OUT_GET / _OUT_CLEAR — response body buffer
  _BRIDGE_SET_RESULT / _BRIDGE_GET_RESULT — fast-path response

Crucially per-thread, not process-global like the original C
implementation. fivenode runs single-threaded under N-API so a static
buffer per process was fine; fivenode_go runs one *hbrt.Thread per
HTTP request goroutine, so the state is keyed by *hbrt.Thread in a
sync.Map. The HTTP dispatcher will call CleanupThread once per
request to keep the map bounded (sub-phase 1a.3-3).

Also exposes Go-side helpers (OutputBytes, Result, SetContextJSON,
CleanupThread) so the dispatcher can seed the context and harvest
the response without bouncing back through PRG.

Verified with app/capi_test.prg: all seven functions behave as
expected; combined with the Five hb_jsonDecode byref fix, ctx_get()
now correctly returns hash values rather than the fallback default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 10:44:13 +09:00
parent 384f957f4e
commit 4a959156ce
3 changed files with 220 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
// Package bridge_capi re-implements the seven C helpers that
// fivenode's PRG layer expects (originally provided by
// fivenode_capi.c + fivenode_buffers.c). The originals back their
// state with process-global static buffers — safe under Node's
// single-threaded N-API caller, unsafe for fivenode_go where every
// HTTP request runs on its own goroutine and *hbrt.Thread.
//
// Here, state is keyed by *hbrt.Thread so concurrent requests don't
// stomp on each other's output buffer or context JSON.
//
// PRG-visible names (identical to fivenode):
//
// _CTX_SET_JSON(cJson) — store the request context as JSON
// _CTX_GET_JSON() -> cJson — read it back
// _OUT_APPEND(cText) — append to the response body buffer
// _OUT_GET() -> cText — read the buffer (host calls this
// after the PRG handler returns)
// _OUT_CLEAR() — reset the buffer
// _BRIDGE_SET_RESULT(cText) — record a fast-path response string
// _BRIDGE_GET_RESULT() -> cText
//
// Memory lifecycle: each request goroutine ends up creating a fresh
// Thread, so the per-thread state in stateMap accumulates as the
// process runs. The HTTP dispatcher (or any other host) calls
// CleanupThread once the request is done — see the public helper
// near the bottom of this file. Without that hook, state for dead
// threads would leak; with it, the map stays bounded by the number
// of in-flight requests.
package bridge_capi
import (
"bytes"
"sync"
"five/hbrt"
)
type state struct {
mu sync.Mutex
ctxJSON string
output bytes.Buffer
resultS string // _BRIDGE_SET_RESULT payload (separate from output buffer)
resultOK bool // distinguishes "" from "never set"
}
var stateMap sync.Map // map[*hbrt.Thread]*state
func stateFor(t *hbrt.Thread) *state {
if v, ok := stateMap.Load(t); ok {
return v.(*state)
}
s := &state{}
actual, _ := stateMap.LoadOrStore(t, s)
return actual.(*state)
}
// CleanupThread releases the per-thread state. The HTTP dispatcher
// invokes this in a defer once the handler returns so the map stays
// bounded by in-flight requests rather than total requests served.
func CleanupThread(t *hbrt.Thread) {
stateMap.Delete(t)
}
// OutputBytes returns a copy of the per-thread output buffer for the
// host to write to the wire after the handler finishes. Returning a
// copy (rather than the underlying slice) avoids any chance of a
// later _OUT_APPEND racing the writer.
func OutputBytes(t *hbrt.Thread) []byte {
s := stateFor(t)
s.mu.Lock()
defer s.mu.Unlock()
if s.output.Len() == 0 {
return nil
}
out := make([]byte, s.output.Len())
copy(out, s.output.Bytes())
return out
}
// Result returns the fast-path result payload if PRG set one via
// _BRIDGE_SET_RESULT, plus an ok flag so callers can distinguish
// "explicitly empty" from "never set".
func Result(t *hbrt.Thread) (string, bool) {
s := stateFor(t)
s.mu.Lock()
defer s.mu.Unlock()
return s.resultS, s.resultOK
}
// SetContextJSON lets the host seed the per-thread context before
// invoking the PRG handler. It's a Go-side mirror of _CTX_SET_JSON
// so the HTTP dispatcher can hand the request hash to PRG without
// going back through PRG itself.
func SetContextJSON(t *hbrt.Thread, jsonStr string) {
s := stateFor(t)
s.mu.Lock()
defer s.mu.Unlock()
s.ctxJSON = jsonStr
}
func init() {
hbrt.HB_FUNC("_CTX_SET_JSON", capiCtxSetJSON)
hbrt.HB_FUNC("_CTX_GET_JSON", capiCtxGetJSON)
hbrt.HB_FUNC("_OUT_APPEND", capiOutAppend)
hbrt.HB_FUNC("_OUT_GET", capiOutGet)
hbrt.HB_FUNC("_OUT_CLEAR", capiOutClear)
hbrt.HB_FUNC("_BRIDGE_SET_RESULT", capiBridgeSetResult)
hbrt.HB_FUNC("_BRIDGE_GET_RESULT", capiBridgeGetResult)
}
func capiCtxSetJSON(ctx *hbrt.HBContext) {
if ctx.PCount() < 1 || !ctx.IsChar(1) {
ctx.RetNil()
return
}
SetContextJSON(ctx.T, ctx.ParC(1))
ctx.RetNil()
}
func capiCtxGetJSON(ctx *hbrt.HBContext) {
s := stateFor(ctx.T)
s.mu.Lock()
v := s.ctxJSON
s.mu.Unlock()
ctx.RetC(v)
}
func capiOutAppend(ctx *hbrt.HBContext) {
if ctx.PCount() < 1 || !ctx.IsChar(1) {
ctx.RetNil()
return
}
s := stateFor(ctx.T)
s.mu.Lock()
s.output.WriteString(ctx.ParC(1))
s.mu.Unlock()
ctx.RetNil()
}
func capiOutGet(ctx *hbrt.HBContext) {
s := stateFor(ctx.T)
s.mu.Lock()
v := s.output.String()
s.mu.Unlock()
ctx.RetC(v)
}
func capiOutClear(ctx *hbrt.HBContext) {
s := stateFor(ctx.T)
s.mu.Lock()
s.output.Reset()
s.mu.Unlock()
ctx.RetNil()
}
func capiBridgeSetResult(ctx *hbrt.HBContext) {
s := stateFor(ctx.T)
s.mu.Lock()
if ctx.PCount() >= 1 && ctx.IsChar(1) {
s.resultS = ctx.ParC(1)
} else {
s.resultS = ""
}
s.resultOK = true
s.mu.Unlock()
ctx.RetNil()
}
func capiBridgeGetResult(ctx *hbrt.HBContext) {
s := stateFor(ctx.T)
s.mu.Lock()
v := s.resultS
s.mu.Unlock()
ctx.RetC(v)
}