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

44
app/capi_test.prg Normal file
View File

@@ -0,0 +1,44 @@
// app/capi_test.prg — verifies bridge_capi RTL in isolation
// (single thread, no HTTP). Multi-thread isolation will be exercised
// once the dispatcher is wired in 1a.3-3.
FUNCTION Main()
LOCAL cJson, cOut, cResult
? "=== _CTX_SET_JSON / _CTX_GET_JSON ==="
_CTX_SET_JSON( '{"user":"alice","role":"admin"}' )
cJson := _CTX_GET_JSON()
? "ctx json:", cJson
? "ctx_get user:", ctx_get( "user", "?" )
? "ctx_get role:", ctx_get( "role", "?" )
? "ctx_get missing:", ctx_get( "nope", "DEFAULT" )
? ""
? "=== _OUT_APPEND / _OUT_GET / _OUT_CLEAR ==="
_OUT_APPEND( "Hello, " )
_OUT_APPEND( "world! " )
_OUT_APPEND( "Three." )
cOut := _OUT_GET()
? "out before clear: [" + cOut + "]"
_OUT_CLEAR()
? "out after clear: [" + _OUT_GET() + "]"
? ""
? "=== _BRIDGE_SET_RESULT / _BRIDGE_GET_RESULT ==="
_BRIDGE_SET_RESULT( "fast-path payload" )
cResult := _BRIDGE_GET_RESULT()
? "result:", cResult
RETURN NIL
// Need ctx_get here because bridge_context.prg isn't linked in yet.
FUNCTION ctx_get( cKey, xDefault )
LOCAL cJson := _CTX_GET_JSON()
LOCAL hCtx
IF Empty( cJson )
RETURN xDefault
ENDIF
hb_jsonDecode( cJson, @hCtx )
IF ValType( hCtx ) == "H" .AND. hb_HHasKey( hCtx, cKey )
RETURN hCtx[ cKey ]
ENDIF
RETURN xDefault

View File

@@ -36,6 +36,7 @@ const version = "fnode 0.1.0 — fivenode_go builder for Five"
var defaultRTL = []string{
"fivenode_go/hbrtl_ext/hello",
"fivenode_go/hbrtl_ext/httpserver",
"fivenode_go/hbrtl_ext/bridge_capi",
}
func main() {

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)
}