From 4a959156ce94c858235e841c84a6c7b27eb32610 Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 10:44:13 +0900 Subject: [PATCH] feat(bridge_capi): port fivenode_capi.c / fivenode_buffers.c to Go RTL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/capi_test.prg | 44 +++++++++ cmd/fnode/main.go | 1 + hbrtl_ext/bridge_capi/capi.go | 175 ++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 app/capi_test.prg create mode 100644 hbrtl_ext/bridge_capi/capi.go diff --git a/app/capi_test.prg b/app/capi_test.prg new file mode 100644 index 0000000..9f4a2d3 --- /dev/null +++ b/app/capi_test.prg @@ -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 diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go index dd58ecc..7973371 100644 --- a/cmd/fnode/main.go +++ b/cmd/fnode/main.go @@ -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() { diff --git a/hbrtl_ext/bridge_capi/capi.go b/hbrtl_ext/bridge_capi/capi.go new file mode 100644 index 0000000..67703b7 --- /dev/null +++ b/hbrtl_ext/bridge_capi/capi.go @@ -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) +}