feat(codegen): Go bindings return typed structs natively

Struct-returning methods now hand back a typed Go struct instead of the raw
CBOR/bytes. Since the native return POD is freed right after the callback, the
POD->Go conversion must happen in-callback: the generator emits a `fromC()`
reader per {.ffi.} type and, per struct-returning proc, an exported Go result
callback. The method calls the native entry point directly with that callback
and a `runtime/cgo.Handle` (boxed in a small C allocation so it travels through
the void* userData checkptr-safe), then blocks until the callback delivers the
typed value or error on the result slot.

String/raw-returning procs keep the existing C-bridge + condvar path. Validated
end-to-end (Echo/Complex/Schedule) including under `go run -race`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ivan FB 2026-05-31 12:14:34 +02:00
parent 98e8e8829f
commit 0a8b53a06d
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
2 changed files with 355 additions and 53 deletions

View File

@ -10,6 +10,9 @@ package my_timer
#include <pthread.h>
extern void my_timerGoEvent(int ret, char* msg, size_t len, void* userData);
extern void my_timerResultEcho(int ret, char* msg, size_t len, void* ud);
extern void my_timerResultComplex(int ret, char* msg, size_t len, void* ud);
extern void my_timerResultSchedule(int ret, char* msg, size_t len, void* ud);
typedef struct {
int ret; char* msg; size_t len; int done;
@ -51,26 +54,11 @@ static void* my_timerCall_my_timer_create(TimerConfig config, My_timerResp* r) {
my_timerRespWait(r);
return ctx;
}
static int my_timerCall_my_timer_echo(void* ctx, EchoRequest req, My_timerResp* r) {
int rc = my_timer_echo(ctx, my_timerRespCb, r, req);
if (rc == RET_OK) my_timerRespWait(r);
return rc;
}
static int my_timerCall_my_timer_version(void* ctx, My_timerResp* r) {
int rc = my_timer_version(ctx, my_timerRespCb, r);
if (rc == RET_OK) my_timerRespWait(r);
return rc;
}
static int my_timerCall_my_timer_complex(void* ctx, ComplexRequest req, My_timerResp* r) {
int rc = my_timer_complex(ctx, my_timerRespCb, r, req);
if (rc == RET_OK) my_timerRespWait(r);
return rc;
}
static int my_timerCall_my_timer_schedule(void* ctx, JobSpec job, RetryPolicy retry, ScheduleConfig schedule, My_timerResp* r) {
int rc = my_timer_schedule(ctx, my_timerRespCb, r, job, retry, schedule);
if (rc == RET_OK) my_timerRespWait(r);
return rc;
}
static int my_timerCall_my_timer_destroy(void* ctx) { return my_timer_destroy(ctx); }
static uint64_t my_timerRegisterEvents(void* ctx) { return my_timer_add_event_listener(ctx, "", (FFICallBack)my_timerGoEvent, ctx); }
*/
@ -78,10 +66,17 @@ import "C"
import (
"errors"
"runtime/cgo"
"sync"
"unsafe"
)
type resultSlot struct {
val any
err error
done chan struct{}
}
// TimerConfig mirrors the {.ffi.} type of the same name.
type TimerConfig struct {
Name string
@ -97,6 +92,13 @@ func (v TimerConfig) toC() (C.TimerConfig, []func()) {
return c, frees
}
// TimerConfigFromC copies a C-POD TimerConfig (e.g. a typed return) into a Go value.
func TimerConfigFromC(c *C.TimerConfig) TimerConfig {
var v TimerConfig
v.Name = C.GoString(c.name)
return v
}
// EchoRequest mirrors the {.ffi.} type of the same name.
type EchoRequest struct {
Message string
@ -114,6 +116,14 @@ func (v EchoRequest) toC() (C.EchoRequest, []func()) {
return c, frees
}
// EchoRequestFromC copies a C-POD EchoRequest (e.g. a typed return) into a Go value.
func EchoRequestFromC(c *C.EchoRequest) EchoRequest {
var v EchoRequest
v.Message = C.GoString(c.message)
v.DelayMs = int64(c.delayMs)
return v
}
// EchoResponse mirrors the {.ffi.} type of the same name.
type EchoResponse struct {
Echoed string
@ -133,6 +143,14 @@ func (v EchoResponse) toC() (C.EchoResponse, []func()) {
return c, frees
}
// EchoResponseFromC copies a C-POD EchoResponse (e.g. a typed return) into a Go value.
func EchoResponseFromC(c *C.EchoResponse) EchoResponse {
var v EchoResponse
v.Echoed = C.GoString(c.echoed)
v.TimerName = C.GoString(c.timerName)
return v
}
// ComplexRequest mirrors the {.ffi.} type of the same name.
type ComplexRequest struct {
Messages []EchoRequest
@ -184,6 +202,34 @@ func (v ComplexRequest) toC() (C.ComplexRequest, []func()) {
return c, frees
}
// ComplexRequestFromC copies a C-POD ComplexRequest (e.g. a typed return) into a Go value.
func ComplexRequestFromC(c *C.ComplexRequest) ComplexRequest {
var v ComplexRequest
if c.messages_len > 0 {
src_messages := unsafe.Slice(c.messages, int(c.messages_len))
v.Messages = make([]EchoRequest, int(c.messages_len))
for i := range src_messages {
v.Messages[i] = EchoRequestFromC(&src_messages[i])
}
}
if c.tags_len > 0 {
src_tags := unsafe.Slice(c.tags, int(c.tags_len))
v.Tags = make([]string, int(c.tags_len))
for i := range src_tags {
v.Tags[i] = C.GoString(src_tags[i])
}
}
if c.note_present != 0 {
tmp_note := C.GoString(c.note)
v.Note = &tmp_note
}
if c.retries_present != 0 {
tmp_retries := int64(c.retries)
v.Retries = &tmp_retries
}
return v
}
// ComplexResponse mirrors the {.ffi.} type of the same name.
type ComplexResponse struct {
Summary string
@ -207,6 +253,15 @@ func (v ComplexResponse) toC() (C.ComplexResponse, []func()) {
return c, frees
}
// ComplexResponseFromC copies a C-POD ComplexResponse (e.g. a typed return) into a Go value.
func ComplexResponseFromC(c *C.ComplexResponse) ComplexResponse {
var v ComplexResponse
v.Summary = C.GoString(c.summary)
v.ItemCount = int64(c.itemCount)
v.HasNote = (c.hasNote != 0)
return v
}
// EchoEvent mirrors the {.ffi.} type of the same name.
type EchoEvent struct {
Message string
@ -224,6 +279,14 @@ func (v EchoEvent) toC() (C.EchoEvent, []func()) {
return c, frees
}
// EchoEventFromC copies a C-POD EchoEvent (e.g. a typed return) into a Go value.
func EchoEventFromC(c *C.EchoEvent) EchoEvent {
var v EchoEvent
v.Message = C.GoString(c.message)
v.EchoCount = int64(c.echoCount)
return v
}
// JobSpec mirrors the {.ffi.} type of the same name.
type JobSpec struct {
Name string
@ -255,6 +318,21 @@ func (v JobSpec) toC() (C.JobSpec, []func()) {
return c, frees
}
// JobSpecFromC copies a C-POD JobSpec (e.g. a typed return) into a Go value.
func JobSpecFromC(c *C.JobSpec) JobSpec {
var v JobSpec
v.Name = C.GoString(c.name)
if c.payload_len > 0 {
src_payload := unsafe.Slice(c.payload, int(c.payload_len))
v.Payload = make([]string, int(c.payload_len))
for i := range src_payload {
v.Payload[i] = C.GoString(src_payload[i])
}
}
v.Priority = int64(c.priority)
return v
}
// RetryPolicy mirrors the {.ffi.} type of the same name.
type RetryPolicy struct {
MaxAttempts int64
@ -284,6 +362,21 @@ func (v RetryPolicy) toC() (C.RetryPolicy, []func()) {
return c, frees
}
// RetryPolicyFromC copies a C-POD RetryPolicy (e.g. a typed return) into a Go value.
func RetryPolicyFromC(c *C.RetryPolicy) RetryPolicy {
var v RetryPolicy
v.MaxAttempts = int64(c.maxAttempts)
v.BackoffMs = int64(c.backoffMs)
if c.retryOn_len > 0 {
src_retryOn := unsafe.Slice(c.retryOn, int(c.retryOn_len))
v.RetryOn = make([]string, int(c.retryOn_len))
for i := range src_retryOn {
v.RetryOn[i] = C.GoString(src_retryOn[i])
}
}
return v
}
// ScheduleConfig mirrors the {.ffi.} type of the same name.
type ScheduleConfig struct {
StartAtMs int64
@ -304,6 +397,18 @@ func (v ScheduleConfig) toC() (C.ScheduleConfig, []func()) {
return c, frees
}
// ScheduleConfigFromC copies a C-POD ScheduleConfig (e.g. a typed return) into a Go value.
func ScheduleConfigFromC(c *C.ScheduleConfig) ScheduleConfig {
var v ScheduleConfig
v.StartAtMs = int64(c.startAtMs)
v.IntervalMs = int64(c.intervalMs)
if c.jitter_present != 0 {
tmp_jitter := int64(c.jitter)
v.Jitter = &tmp_jitter
}
return v
}
// ScheduleResult mirrors the {.ffi.} type of the same name.
type ScheduleResult struct {
JobId string
@ -325,6 +430,16 @@ func (v ScheduleResult) toC() (C.ScheduleResult, []func()) {
return c, frees
}
// ScheduleResultFromC copies a C-POD ScheduleResult (e.g. a typed return) into a Go value.
func ScheduleResultFromC(c *C.ScheduleResult) ScheduleResult {
var v ScheduleResult
v.JobId = C.GoString(c.jobId)
v.WillRunCount = int64(c.willRunCount)
v.FirstRunAtMs = int64(c.firstRunAtMs)
v.EffectiveBackoffMs = int64(c.effectiveBackoffMs)
return v
}
type My_timerNode struct {
ctx unsafe.Pointer
}
@ -374,20 +489,39 @@ func NewMy_timer(config TimerConfig) (*My_timerNode, error) {
return &My_timerNode{ctx: ctx}, nil
}
func (n *My_timerNode) Echo(req EchoRequest) (string, error) {
//export my_timerResultEcho
func my_timerResultEcho(ret C.int, msg *C.char, length C.size_t, ud unsafe.Pointer) {
slot := cgo.Handle(*(*C.uintptr_t)(ud)).Value().(*resultSlot)
if ret == C.RET_OK {
slot.val = EchoResponseFromC((*C.EchoResponse)(unsafe.Pointer(msg)))
} else {
slot.err = errors.New(C.GoStringN(msg, C.int(length)))
}
close(slot.done)
}
func (n *My_timerNode) Echo(req EchoRequest) (EchoResponse, error) {
c_req, free_req := req.toC()
defer func() {
for _, f := range free_req {
f()
}
}()
r := C.my_timerRespNew()
defer C.my_timerRespFree(r)
C.my_timerCall_my_timer_echo(n.ctx, c_req, r)
if C.my_timerRespRet(r) != C.RET_OK {
return "", errors.New(respStr(r))
slot := &resultSlot{done: make(chan struct{})}
h := cgo.NewHandle(slot)
defer h.Delete()
hbox := (*C.uintptr_t)(C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0)))))
*hbox = C.uintptr_t(h)
defer C.free(unsafe.Pointer(hbox))
rc := C.my_timer_echo(n.ctx, C.FFICallBack(C.my_timerResultEcho), unsafe.Pointer(hbox), c_req)
if rc != C.RET_OK {
return EchoResponse{}, errors.New("my_timer_echo: dispatch failed")
}
return respStr(r), nil
<-slot.done
if slot.err != nil {
return EchoResponse{}, slot.err
}
return slot.val.(EchoResponse), nil
}
func (n *My_timerNode) Version() (string, error) {
@ -400,23 +534,53 @@ func (n *My_timerNode) Version() (string, error) {
return respStr(r), nil
}
func (n *My_timerNode) Complex(req ComplexRequest) (string, error) {
//export my_timerResultComplex
func my_timerResultComplex(ret C.int, msg *C.char, length C.size_t, ud unsafe.Pointer) {
slot := cgo.Handle(*(*C.uintptr_t)(ud)).Value().(*resultSlot)
if ret == C.RET_OK {
slot.val = ComplexResponseFromC((*C.ComplexResponse)(unsafe.Pointer(msg)))
} else {
slot.err = errors.New(C.GoStringN(msg, C.int(length)))
}
close(slot.done)
}
func (n *My_timerNode) Complex(req ComplexRequest) (ComplexResponse, error) {
c_req, free_req := req.toC()
defer func() {
for _, f := range free_req {
f()
}
}()
r := C.my_timerRespNew()
defer C.my_timerRespFree(r)
C.my_timerCall_my_timer_complex(n.ctx, c_req, r)
if C.my_timerRespRet(r) != C.RET_OK {
return "", errors.New(respStr(r))
slot := &resultSlot{done: make(chan struct{})}
h := cgo.NewHandle(slot)
defer h.Delete()
hbox := (*C.uintptr_t)(C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0)))))
*hbox = C.uintptr_t(h)
defer C.free(unsafe.Pointer(hbox))
rc := C.my_timer_complex(n.ctx, C.FFICallBack(C.my_timerResultComplex), unsafe.Pointer(hbox), c_req)
if rc != C.RET_OK {
return ComplexResponse{}, errors.New("my_timer_complex: dispatch failed")
}
return respStr(r), nil
<-slot.done
if slot.err != nil {
return ComplexResponse{}, slot.err
}
return slot.val.(ComplexResponse), nil
}
func (n *My_timerNode) Schedule(job JobSpec, retry RetryPolicy, schedule ScheduleConfig) (string, error) {
//export my_timerResultSchedule
func my_timerResultSchedule(ret C.int, msg *C.char, length C.size_t, ud unsafe.Pointer) {
slot := cgo.Handle(*(*C.uintptr_t)(ud)).Value().(*resultSlot)
if ret == C.RET_OK {
slot.val = ScheduleResultFromC((*C.ScheduleResult)(unsafe.Pointer(msg)))
} else {
slot.err = errors.New(C.GoStringN(msg, C.int(length)))
}
close(slot.done)
}
func (n *My_timerNode) Schedule(job JobSpec, retry RetryPolicy, schedule ScheduleConfig) (ScheduleResult, error) {
c_job, free_job := job.toC()
defer func() {
for _, f := range free_job {
@ -435,13 +599,21 @@ func (n *My_timerNode) Schedule(job JobSpec, retry RetryPolicy, schedule Schedul
f()
}
}()
r := C.my_timerRespNew()
defer C.my_timerRespFree(r)
C.my_timerCall_my_timer_schedule(n.ctx, c_job, c_retry, c_schedule, r)
if C.my_timerRespRet(r) != C.RET_OK {
return "", errors.New(respStr(r))
slot := &resultSlot{done: make(chan struct{})}
h := cgo.NewHandle(slot)
defer h.Delete()
hbox := (*C.uintptr_t)(C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0)))))
*hbox = C.uintptr_t(h)
defer C.free(unsafe.Pointer(hbox))
rc := C.my_timer_schedule(n.ctx, C.FFICallBack(C.my_timerResultSchedule), unsafe.Pointer(hbox), c_job, c_retry, c_schedule)
if rc != C.RET_OK {
return ScheduleResult{}, errors.New("my_timer_schedule: dispatch failed")
}
return respStr(r), nil
<-slot.done
if slot.err != nil {
return ScheduleResult{}, slot.err
}
return slot.val.(ScheduleResult), nil
}
func (n *My_timerNode) Destroy() error {

View File

@ -224,8 +224,50 @@ proc goMarshalField(f: FFIFieldMeta, types: seq[FFITypeMeta]): seq[string] =
lines.add(ln)
return lines
# --- reverse marshalling: C-POD -> Go (for typed struct returns) ------------
# Used inside the result callback, where the POD is still alive; everything is
# copied out into Go-owned memory before the library frees the POD.
proc fromCExpr(src, typeName: string, types: seq[FFITypeMeta]): string =
## A Go expression converting the cgo value `src` to its Go type.
let t = typeName.strip()
if isStringT(t): "C.GoString(" & src & ")"
elif isFFIStruct(t, types): t & "FromC(&" & src & ")"
elif t == "bool": "(" & src & " != 0)"
else: nimTypeToGo(t) & "(" & src & ")"
proc goUnmarshalField(f: FFIFieldMeta, types: seq[FFITypeMeta]): seq[string] =
## Copy `c.<field>` (+ `_len` / `_present`) out into `v.<Field>`.
var lines: seq[string] = @[]
let cname = f.name
let gofld = capitalizeFirstLetter(f.name)
let t = f.typeName.strip()
if isSeqT(t):
let elem = seqElemT(t)
let elemGo = goFieldType(elem, types)
lines.add("\tif c." & cname & "_len > 0 {")
lines.add(
"\t\tsrc_" & cname & " := unsafe.Slice(c." & cname & ", int(c." & cname & "_len))"
)
lines.add("\t\tv." & gofld & " = make([]" & elemGo & ", int(c." & cname & "_len))")
lines.add("\t\tfor i := range src_" & cname & " {")
lines.add(
"\t\t\tv." & gofld & "[i] = " & fromCExpr("src_" & cname & "[i]", elem, types)
)
lines.add("\t\t}")
lines.add("\t}")
elif isOptT(t):
let elem = optElemT(t)
lines.add("\tif c." & cname & "_present != 0 {")
lines.add("\t\ttmp_" & cname & " := " & fromCExpr("c." & cname, elem, types))
lines.add("\t\tv." & gofld & " = &tmp_" & cname)
lines.add("\t}")
else:
lines.add("\tv." & gofld & " = " & fromCExpr("c." & cname, t, types))
return lines
proc emitGoTypesAndToC(types: seq[FFITypeMeta]): seq[string] =
## A Go struct + `toC()` marshaller for every {.ffi.} type.
## A Go struct + `toC()` marshaller + `fromC()` reader for every {.ffi.} type.
var lines: seq[string] = @[]
for ty in types:
lines.add("// " & ty.name & " mirrors the {.ffi.} type of the same name.")
@ -247,6 +289,18 @@ proc emitGoTypesAndToC(types: seq[FFITypeMeta]): seq[string] =
lines.add("\treturn c, frees")
lines.add("}")
lines.add("")
lines.add(
"// " & ty.name & "FromC copies a C-POD " & ty.name & " (e.g. a typed return" &
") into a Go value."
)
lines.add("func " & ty.name & "FromC(c *C." & ty.name & ") " & ty.name & " {")
lines.add("\tvar v " & ty.name)
for f in ty.fields:
for ln in goUnmarshalField(f, types):
lines.add(ln)
lines.add("\treturn v")
lines.add("}")
lines.add("")
return lines
proc goParamConv(
@ -316,6 +370,15 @@ proc generateGoFile*(
L.add(
"extern void " & libName & "GoEvent(int ret, char* msg, size_t len, void* userData);"
)
# One exported Go result callback per struct-returning proc (it reads the typed
# return POD in-callback). Forward-declared here so cgo's `char*` shape matches.
for p in procs:
if p.kind == FFIKind.FFI and allSupported(p, types) and
isFFIStruct(p.returnTypeName, types):
L.add(
"extern void " & libName & "Result" & methodName(p.procName, libName) &
"(int ret, char* msg, size_t len, void* ud);"
)
L.add("")
L.add("typedef struct {")
L.add(" int ret; char* msg; size_t len; int done;")
@ -386,10 +449,14 @@ proc generateGoFile*(
L.add(" return ctx;")
L.add("}")
# per-proc bridges
# per-proc bridges — only for string/raw-returning procs. Struct-returning
# procs call the native entry point directly from Go with an exported Go
# callback (the C RespCb would copy struct bytes whose pointers then dangle).
for p in procs:
if p.kind != FFIKind.FFI or not allSupported(p, types):
continue
if isFFIStruct(p.returnTypeName, types):
continue
var cparams: seq[string] = @[]
var callArgs: seq[string] = @[]
for ep in p.extraParams:
@ -431,10 +498,19 @@ proc generateGoFile*(
L.add("")
L.add("import (")
L.add("\t\"errors\"")
L.add("\t\"runtime/cgo\"")
L.add("\t\"sync\"")
L.add("\t\"unsafe\"")
L.add(")")
L.add("")
# Carries one typed result from the FFI-thread callback back to the blocked
# caller goroutine; passed through C as a cgo.Handle token (stable under GC).
L.add("type resultSlot struct {")
L.add("\tval any")
L.add("\terr error")
L.add("\tdone chan struct{}")
L.add("}")
L.add("")
# ---- Go mirrors of the {.ffi.} types (with C-POD marshalling) ------------
for line in emitGoTypesAndToC(types):
@ -525,21 +601,75 @@ proc generateGoFile*(
", " & callArgs.join(", ")
else:
""
L.add(
"func (n *" & nodeType & ") " & mName & "(" & goParams.join(", ") &
") (string, error) {"
)
for c in conv:
L.add(c)
L.add("\tr := C." & libName & "RespNew()")
L.add("\tdefer C." & libName & "RespFree(r)")
L.add("\tC." & libName & "Call_" & p.procName & "(n.ctx" & callArgsStr & ", r)")
L.add("\tif C." & libName & "RespRet(r) != C.RET_OK {")
L.add("\t\treturn \"\", errors.New(respStr(r))")
L.add("\t}")
L.add("\treturn respStr(r), nil")
L.add("}")
L.add("")
if isFFIStruct(p.returnTypeName, types):
# Typed struct return: call the native entry point directly with an
# exported Go callback; a cgo.Handle token carries the result slot through
# C (stable under GC) so the async callback can deliver the typed value.
let retT = p.returnTypeName
let cbName = libName & "Result" & mName
L.add("//export " & cbName)
L.add(
"func " & cbName &
"(ret C.int, msg *C.char, length C.size_t, ud unsafe.Pointer) {"
)
L.add("\tslot := cgo.Handle(*(*C.uintptr_t)(ud)).Value().(*resultSlot)")
L.add("\tif ret == C.RET_OK {")
L.add("\t\tslot.val = " & retT & "FromC((*C." & retT & ")(unsafe.Pointer(msg)))")
L.add("\t} else {")
L.add("\t\tslot.err = errors.New(C.GoStringN(msg, C.int(length)))")
L.add("\t}")
L.add("\tclose(slot.done)")
L.add("}")
L.add("")
L.add(
"func (n *" & nodeType & ") " & mName & "(" & goParams.join(", ") & ") (" &
retT & ", error) {"
)
for c in conv:
L.add(c)
L.add("\tslot := &resultSlot{done: make(chan struct{})}")
L.add("\th := cgo.NewHandle(slot)")
L.add("\tdefer h.Delete()")
# Box the cgo.Handle in a small C allocation and pass that real C pointer as
# the void* userData. The handle is a stable GC-safe token; boxing it (vs.
# casting it straight to unsafe.Pointer) keeps -race/checkptr happy.
L.add(
"\thbox := (*C.uintptr_t)(C.malloc(C.size_t(unsafe.Sizeof(C.uintptr_t(0)))))"
)
L.add("\t*hbox = C.uintptr_t(h)")
L.add("\tdefer C.free(unsafe.Pointer(hbox))")
L.add(
"\trc := C." & p.procName &
"(n.ctx, C.FFICallBack(C." & cbName & "), unsafe.Pointer(hbox)" &
callArgsStr & ")"
)
L.add("\tif rc != C.RET_OK {")
L.add("\t\treturn " & retT & "{}, errors.New(\"" & p.procName & ": dispatch failed\")")
L.add("\t}")
L.add("\t<-slot.done")
L.add("\tif slot.err != nil {")
L.add("\t\treturn " & retT & "{}, slot.err")
L.add("\t}")
L.add("\treturn slot.val.(" & retT & "), nil")
L.add("}")
L.add("")
else:
L.add(
"func (n *" & nodeType & ") " & mName & "(" & goParams.join(", ") &
") (string, error) {"
)
for c in conv:
L.add(c)
L.add("\tr := C." & libName & "RespNew()")
L.add("\tdefer C." & libName & "RespFree(r)")
L.add("\tC." & libName & "Call_" & p.procName & "(n.ctx" & callArgsStr & ", r)")
L.add("\tif C." & libName & "RespRet(r) != C.RET_OK {")
L.add("\t\treturn \"\", errors.New(respStr(r))")
L.add("\t}")
L.add("\treturn respStr(r), nil")
L.add("}")
L.add("")
# ---- destructor ----------------------------------------------------------
if haveDtor: