diff --git a/CHANGELOG.md b/CHANGELOG.md index eca2b7f..0975823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,19 @@ All notable changes to this project are documented in this file. longer need a hand-written payload type. A single parameter still rides the wire directly (a scalar, or an existing `{.ffi.}` object). The foreign bindings gain the envelope as a first-class struct plus a typed handler. -- `{.ffiExport.}`, for simple synchronous C exports, from the 0.2 line. +- `{.ffiExport.}`, for simple synchronous C exports, from the 0.2 line. Each + wrapper carries `raises: []` and catches every exception of the body, because + an exception must not cross the C ABI. A return type with no C ABI mapping is + a compile error. `int` maps to `long long`, so a 64-bit value keeps its full + width. A `string` return rides in a buffer that belongs to the calling thread + and stays valid until that thread calls another string export. +- Pooled FFI contexts are recycled instead of destroyed. Each slot builds its + worker thread, its event thread and its signal fds once, then reuses them, so + repeated create/destroy no longer churns fds past `FD_SETSIZE`. The `ffiDtor` + asks the FFI thread to drain the in-flight handlers, free the library and + return the slot, while the threads stay alive. A recycle also fails every + request still queued for that slot, because such a request carries the + `userData` of a host that is gone. - `{.ffi.}` now picks the path from the shape of the signature. One pragma covers the context method, the static call, the synchronous export, the destructor and the event. `ffi/internal/ffi_route.nim` holds the rules: diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 740d736..7dc1e99 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -37,8 +37,8 @@ type FFIContext*[T] = object # the FFI thread so the slot returns to the pool without recreating threads. lifecycle*: Atomic[CtxLifecycle] recycleDoneSignal: ThreadSignalPtr - # fired by the recycle handler once the lib is freed and the slot released; - # the synchronous recycleFFIContext caller waits on it. + # fired by the recycle handler once the lib is freed, just before it releases + # the slot; the synchronous recycleFFIContext caller waits on it. libReady*: Atomic[bool] # False until a {.ffiCtor.} stores the library. Before that, `myLib` points # at the default fallback of the FFI thread. For a `ref` type that fallback @@ -67,10 +67,16 @@ var onFFIThread* {.threadvar.}: bool const git_version* {.strdefine.} = "n/a" +const RecycleTimeoutMs* {.intdefine: "ffiRecycleTimeoutMs".} = 1500 + ## Bounds one drain round of the recycle handler. The handler runs at most two + ## rounds: it waits for the in-flight handlers, then cancels them and waits + ## again. Override with `-d:ffiRecycleTimeoutMs=`. +const RecycleTimeout* = RecycleTimeoutMs.milliseconds + const - RecycleWaitTimeout* = 5.seconds - ## Caller-side bound for synchronous recycle; the FFI-thread drain itself is - ## bounded by RecycleTimeout, so this only guards against a wedged worker. + RecycleWaitTimeout* = 2 * RecycleTimeout + 2.seconds + ## Caller-side bound for synchronous recycle. It covers both drain rounds + ## plus slack, so it only fires when the worker itself is wedged. EventThreadTickInterval* = 1.seconds FFIHeartbeatStartDelay* = 10.seconds FFIHeartbeatStaleThreshold* = 1.seconds @@ -209,7 +215,7 @@ proc tryClaim*[T](ctx: ptr FFIContext[T]): bool = var expected = false ctx.inUse.compareExchange(expected, true) -proc release*[T](ctx: ptr FFIContext[T]) = +proc releaseClaim*[T](ctx: ptr FFIContext[T]) = ctx.inUse.store(false) proc isInUse*[T](ctx: ptr FFIContext[T]): bool = @@ -227,6 +233,10 @@ proc requestRecycle*[T](ctx: ptr FFIContext[T]): Result[void, string] = if not ctx.lifecycle.compareExchange(expected, CtxLifecycle.RecyclePending): return err("requestRecycle: context is not Active (already recycling)") + # A recycle that timed out can fire late. The CAS makes this the only recycle + # in flight, so drop that stale fire before the wait below can answer to it. + discard ctx.recycleDoneSignal.waitSync(ZeroDuration) + let fired = ctx.reqSignal.fireSync().valueOr: return err("requestRecycle: failed to signal the FFI thread: " & $error) if not fired: diff --git a/ffi/ffi_context_pool.nim b/ffi/ffi_context_pool.nim index a6e351a..8bb6cc9 100644 --- a/ffi/ffi_context_pool.nim +++ b/ffi/ffi_context_pool.nim @@ -29,7 +29,7 @@ proc releaseSlot[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]) = if pool.contexts[i].addr == ctx: pool.initialized[i].store(false) break - ctx.release() + ctx.releaseClaim() proc createFFIContext*[T]( pool: var FFIContextPool[T] @@ -45,7 +45,7 @@ proc createFFIContext*[T]( ctx.markAsActive() return ok(ctx) initContextResources(ctx).isOkOr: - ctx.release() + ctx.releaseClaim() return err("createFFIContext: initContextResources failed: " & $error) pool.initialized[i].store(true) return ok(ctx) @@ -138,5 +138,5 @@ proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool = return false for i in 0 ..< MaxFFIContexts: if cast[pointer](pool.contexts[i].addr) == ctx: - return cast[ptr FFIContext[T]](ctx).isInUse() + return pool.contexts[i].addr.isInUse() false diff --git a/ffi/ffi_thread.nim b/ffi/ffi_thread.nim index 9084e89..c8a9317 100644 --- a/ffi/ffi_thread.nim +++ b/ffi/ffi_thread.nim @@ -86,10 +86,6 @@ proc processRequest[T]( except Exception as e: error "Unexpected exception in handleRes", error = e.msg -const RecycleTimeout = 1500.milliseconds - ## Bounds how long the recycle handler waits for in-flight handlers before it - ## cancels them, so a wedged handler cannot block reuse forever. - proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} = ## Releases the library object the ctor stored in ctx.myLib. Only owned libs ## (createShared'd by a ctor) are freed; the worker's stack fallback is not. @@ -102,8 +98,8 @@ proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} = try: {.cast(gcsafe).}: `=destroy`(ctx.myLib[]) - except Exception: - discard + except Exception as e: + error "destroying the library on recycle raised; freeing it anyway", error = e.msg else: when T is ref: if ctx.myLibRefd: @@ -113,6 +109,23 @@ proc freeLib[T](ctx: ptr FFIContext[T]) {.gcsafe.} = ctx.myLib = nil ctx.myLibOwned = false +const RecycledReason = + "FFI context was recycled before this request ran; the caller is gone" + +proc rejectQueuedRequests[T](ctx: ptr FFIContext[T]) = + ## Fails every queued request instead of dispatching it. A request that a + ## destroyed context left behind still carries that host's `userData`, which + ## the host has freed; running it would answer a dead callback, and running it + ## after the slot is reused would run it against the library of the next owner. + var request = ctx.reqQueueBank.mergeQueues() + while not request.isNil(): + let nextRequest = request[].next # read before handleRes frees it + try: + handleRes(Result[seq[byte], string].err(RecycledReason), request) + except Exception as e: + error "rejecting a queued request raised", error = e.msg + request = nextRequest + proc recycleContext[T]( ctx: ptr FFIContext[T], ongoing: ptr seq[Future[void]] ) {.async.} = @@ -125,18 +138,20 @@ proc recycleContext[T]( drained = await allFutures(ongoing[]).withTimeout(RecycleTimeout) if not drained: for fut in ongoing[]: - if not fut.finished(): - fut.cancelSoon() + fut.cancelSoon() drained = await allFutures(ongoing[]).withTimeout(RecycleTimeout) freeLib(ctx) clearListeners(ctx[].eventRegistry) + rejectQueuedRequests(ctx) ongoing[].setLen(0) - ctx.release() + # Fire before the release: a thread that claims the freed slot first would + # otherwise take this fire as the answer to its own recycle. let fireRes = ctx.recycleDoneSignal.fireSync() if fireRes.isErr(): error "failed to fire recycleDoneSignal", err = fireRes.error + ctx.releaseClaim() var ffiEventQueueSignalPtr {.threadvar.}: ThreadSignalPtr # Stashed so the hook has no closure env. @@ -215,6 +230,13 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} = await recycleContext(ctx, addr pending) continue + # A submit that read `Active` just before the recycle can still land here. + # Fail it rather than run it against the library of the next owner. + if ctx.lifecycle.load() != CtxLifecycle.Active: + rejectQueuedRequests(ctx) + discard await ctx.reqSignal.wait().withTimeout(chronos.milliseconds(100)) + continue + cleanFinishedRequests() # Block until a submit signals us, or at most 100ms. diff --git a/ffi/internal/ffi_codegen_common.nim b/ffi/internal/ffi_codegen_common.nim new file mode 100644 index 0000000..0dd9242 --- /dev/null +++ b/ffi/internal/ffi_codegen_common.nim @@ -0,0 +1,29 @@ +## Compile-time pieces that more than one `{.ffi.}` codegen path shares. + +import std/macros + +func unwrapPostfix*(n: NimNode): NimNode = + ## Strips the `*` of an exported name, so a name node reads the same whether + ## the writer exported it or not. + return + if n.kind == nnkPostfix: + n[1] + else: + n + +func procIdent*(prc: NimNode): NimNode = + return unwrapPostfix(prc[0]) + +proc buildLibReadyGuard*( + ctxHandlerName, libTypeName: NimNode +): NimNode {.compileTime.} = + ## Rejects a request that reaches the FFI thread before the ctor stores a + ## library. The guard applies only to a `ref` type. For an `object` type the + ## fallback is a usable zero value, but for a `ref` type it is nil. The guard + ## runs in the handler, behind the ctor in the queue. Thus a host can send a + ## call before it waits for the create callback. + quote: + when `libTypeName` is ref: + if not `ctxHandlerName`[].libReady.load(): + return + err("library is not initialized: the constructor failed or has not run yet") diff --git a/ffi/internal/ffi_export.nim b/ffi/internal/ffi_export.nim index 86bba8d..fa7567e 100644 --- a/ffi/internal/ffi_export.nim +++ b/ffi/internal/ffi_export.nim @@ -8,15 +8,22 @@ ## return value of the function crosses the ABI directly. ## ## Write native Nim types. `ffiExport` maps them to the C ABI: -## int / bool -> C int -## uint64 -> C unsigned long long -## string -> C const char* (stays alive in shared memory until the next call) -## (no return) -> C void +## int / int64 -> C long long +## int32 / bool -> C int +## uint / uint64 -> C unsigned long long +## float -> C double +## string -> C const char* (valid until the same thread calls again) +## (no return) -> C void +## A return type with no mapping is a compile error. +## The `const char*` buffer belongs to the calling thread. Copy the bytes before +## that thread calls another `{.ffiExport.}` proc that returns a string. ## `ffiExport` also injects the `initializeLibrary()` call of the library. The Nim ## runtime therefore starts on the first call, and the host never calls NimMain. +## The wrapper catches every exception of the body, prints it and returns the +## zero value, because an exception must not cross the C ABI. ## ## declareLibraryBase("myLib") # emits initializeLibrary() -## proc my_start(): int {.ffiExport.} = 0 # -> int my_start(void) +## proc my_start(): int {.ffiExport.} = 0 # -> long long my_start(void) ## proc my_alive(): uint64 {.ffiExport.} = beats # -> unsigned long long my_alive(void) ## proc my_error(): string {.ffiExport.} = lastErr # -> const char* my_error(void) ## @@ -25,22 +32,65 @@ import std/macros import ./ffi_route +import ./ffi_codegen_common -proc cReturnType(t: NimNode): NimNode = +const passthroughCTypes = [ + "cint", "cuint", "clong", "culong", "clonglong", "culonglong", "cfloat", "cdouble", + "cstring", "pointer", +] + +proc cReturnType(t: NimNode, exportName: string): NimNode = ## Maps the native Nim return type to the C ABI type that crosses the boundary. + ## A type with no mapping is an error: emitting the Nim type as-is would export + ## a symbol whose ABI no host can call. if t.kind == nnkEmpty: - return t # void + return t if t.kind == nnkIdent: case $t - of "int", "int32", "bool": + of "int", "int64": + # `int` is pointer-wide, so `cint` would truncate it on a 64-bit host. + return ident("clonglong") + of "int8", "int16", "int32", "bool": return ident("cint") of "uint", "uint64": return ident("culonglong") + of "uint8", "uint16", "uint32": + return ident("cuint") + of "float", "float64": + return ident("cdouble") + of "float32": + return ident("cfloat") of "string": return ident("cstring") else: - discard - return t # already a C-compatible type + if $t in passthroughCTypes: + return t + error( + "`.ffiExport.` proc " & exportName & " returns " & t.repr & + ", which has no C ABI mapping. Return a scalar, a bool, a string, a C type, " & + "or nothing. For a richer return type, use `{.ffi.}`." + ) + +proc withoutFFIPragmas(pragmas: NimNode): NimNode = + ## Drops only the pragma that routed the proc here, so a `raises` or `gcsafe` + ## the writer asked for still applies to the body. + if pragmas.kind != nnkPragma: + return newEmptyNode() + var kept = nnkPragma.newTree() + for p in pragmas: + let name = + if p.kind in {nnkExprColonExpr, nnkCall}: + p[0] + else: + p + if name.kind == nnkIdent and $name in ["ffi", "ffiExport"]: + continue + kept.add(p) + return + if kept.len == 0: + newEmptyNode() + else: + kept proc buildFFIExportProc*(prc: NimNode): NimNode {.compileTime.} = ## Emits the synchronous C export. `{.ffi.}` and `{.ffiExport.}` share it. @@ -48,50 +98,64 @@ proc buildFFIExportProc*(prc: NimNode): NimNode {.compileTime.} = let exportName = $procIdent(prc) let params = prc.params let nativeRet = params[0] - let cRet = cReturnType(nativeRet) + let cRet = cReturnType(nativeRet, exportName) - # The user body becomes a private impl proc. The exported wrapper converts the - # result. + # The user body becomes a private impl proc that the exported wrapper calls. let implName = genSym(nskProc, exportName & "Impl") var impl = copyNimTree(prc) - impl[0] = implName # rename - impl[4] = newEmptyNode() # remove the pragmas: this proc stays internal + impl[0] = implName + impl[4] = withoutFFIPragmas(prc[4]) let wrapName = ident(exportName) let boot = quote: when declared(initializeLibrary): initializeLibrary() + # A Nim exception that unwinds through a cdecl frame into the host is + # undefined behaviour, so every wrapper catches and reports instead. + let raiseNote = newLit("error: " & exportName & " raised: ") + var res = newStmtList(impl) if nativeRet.kind == nnkIdent and $nativeRet == "string": - # string -> const char*: keep the bytes alive in shared memory across the call. + # One buffer per calling thread: a process-wide buffer would let one thread + # free the bytes another thread is still reading. let buf = genSym(nskVar, exportName & "Buf") res.add quote do: - var `buf` {.global.}: pointer = nil - proc `wrapName`(): cstring {.exportc: `exportName`, cdecl, dynlib.} = + var `buf` {.threadvar.}: pointer + proc `wrapName`(): cstring {.exportc: `exportName`, cdecl, dynlib, raises: [].} = `boot` - let s = `implName`() - if `buf` != nil: + var s = "" + try: + s = `implName`() + except CatchableError as e: + echo `raiseNote`, e.msg + if not `buf`.isNil(): deallocShared(`buf`) `buf` = allocShared(s.len + 1) if s.len > 0: copyMem(`buf`, unsafeAddr s[0], s.len) - cast[ptr char](cast[uint](`buf`) + uint(s.len))[] = '\0' + cast[ptr UncheckedArray[char]](`buf`)[s.len] = '\0' return cast[cstring](`buf`) elif nativeRet.kind == nnkEmpty: res.add quote do: - proc `wrapName`() {.exportc: `exportName`, cdecl, dynlib.} = + proc `wrapName`() {.exportc: `exportName`, cdecl, dynlib, raises: [].} = `boot` - `implName`() + try: + `implName`() + except CatchableError as e: + echo `raiseNote`, e.msg else: - # scalar: convert the native result to the C return type (cint / culonglong / …). res.add quote do: - proc `wrapName`(): `cRet` {.exportc: `exportName`, cdecl, dynlib.} = + proc `wrapName`(): `cRet` {.exportc: `exportName`, cdecl, dynlib, raises: [].} = `boot` - return `cRet`(`implName`()) + try: + return `cRet`(`implName`()) + except CatchableError as e: + echo `raiseNote`, e.msg + return `cRet`(0) return res diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 6f43bd8..d30980c 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -9,6 +9,7 @@ import ./c_macro_helpers import ./ffi_scalar import ./ffi_route import ./ffi_export +import ./ffi_codegen_common when defined(ffiGenBindings): import ../codegen/rust import ../codegen/cpp @@ -394,12 +395,7 @@ proc buildFFINewReqProc(reqTypeName, body: NimNode): NimNode = `reqObjIdent`.`fieldName` = `fieldName` ) - let reqNameLit = newLit( - if reqTypeName.kind == nnkPostfix: - $reqTypeName[1] - else: - $reqTypeName - ) + let reqNameLit = newLit($unwrapPostfix(reqTypeName)) newBody.add( quote do: # Encode into shared memory, avoiding a second seq[byte] copy. @@ -1087,12 +1083,7 @@ proc buildFFIProc( ffiBody.add(stmt) let reqPtrIdent = genSym(nskLet, "reqPtr") - let reqNameLit = newLit( - if reqTypeName.kind == nnkPostfix: - $reqTypeName[1] - else: - $reqTypeName - ) + let reqNameLit = newLit($unwrapPostfix(reqTypeName)) ffiBody.add quote do: let `reqPtrIdent` = FFIThreadRequest.initFromPtr( callback, userData, cstring(`reqNameLit`), reqCbor, int(reqCborLen) @@ -1173,15 +1164,16 @@ macro ffi*(args: varargs[untyped]): untyped = error("`.ffi.` must be applied to a type or a proc definition") requireLibraryDeclared("`.ffi.`") - let path = routeFFIProc(prc) + proc gatedABIFormat(what: string): ABIFormat = + let abiFormat = resolveFFISpecs(leading) + gateABIFormat(abiFormat, what) + return abiFormat - # An event may lead with a wire-name literal, which the ABI parser rejects, so - # it resolves its own specs. - if path == fpEvent: + case routeFFIProc(prc) + of fpEvent: + # An event may lead with a wire-name literal, which the ABI parser rejects, + # so it resolves its own specs. return buildFFIEventProc(prc, leading) - - let abiFormat = resolveFFISpecs(leading) - case path of fpExport: # The export crosses the ABI with its own return value, so no ABI applies. if leading.len > 0: @@ -1191,15 +1183,11 @@ macro ffi*(args: varargs[untyped]): untyped = ) return buildFFIExportProc(prc) of fpDtor: - gateABIFormat(abiFormat, "`.ffi.` destructor") - return buildFFIDtorProc(prc, abiFormat) + return buildFFIDtorProc(prc, gatedABIFormat("`.ffi.` destructor")) of fpStatic: - gateABIFormat(abiFormat, "`.ffi.` static proc") - return buildFFIProc(prc, abiFormat, isStatic = true) + return buildFFIProc(prc, gatedABIFormat("`.ffi.` static proc"), isStatic = true) of fpMethod: - return buildFFIProc(prc, abiFormat, isStatic = false) - of fpEvent: - error("unreachable: the event path returns above") + return buildFFIProc(prc, resolveFFISpecs(leading), isStatic = false) macro ffiStatic*(args: varargs[untyped]): untyped = ## Context-independent `{.ffi.}`: no library receiver, and no `ctx` in the C @@ -1259,12 +1247,7 @@ proc buildCtorFFINewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimN let retType = newTree(nnkPtrTy, ident("FFIThreadRequest")) formalParams = @[retType] & formalParams - let reqNameLit = newLit( - if reqTypeName.kind == nnkPostfix: - $reqTypeName[1] - else: - $reqTypeName - ) + let reqNameLit = newLit($unwrapPostfix(reqTypeName)) var newBody = newStmtList() newBody.add quote do: return FFIThreadRequest.initFromPtr( @@ -1846,7 +1829,7 @@ proc buildFFIEventProc(prc: NimNode, leading: seq[NimNode]): NimNode {.compileTi newStmtList(newCall(ident("dispatchFFIEventCbor"), wireNameLit, dispatchPayload)) var newParams = newSeq[NimNode]() - newParams.add(formalParams[0]) # return type (typically empty/void) + newParams.add(formalParams[0]) for i in 1 ..< formalParams.len: newParams.add(formalParams[i]) diff --git a/ffi/internal/ffi_route.nim b/ffi/internal/ffi_route.nim index 154d001..fe73386 100644 --- a/ffi/internal/ffi_route.nim +++ b/ffi/internal/ffi_route.nim @@ -12,6 +12,7 @@ import std/macros import ../codegen/meta +import ./ffi_codegen_common type FFIPath* = enum fpMethod ## A library or handle receiver, and an async result. @@ -56,16 +57,9 @@ proc isLibReceiver(t: NimNode): bool {.compileTime.} = return false return ($t == currentLibType and currentLibType.len > 0) or isFFIHandleTypeName($t) -func procIdent*(prc: NimNode): NimNode = - return - if prc[0].kind == nnkPostfix: - prc[0][1] - else: - prc[0] - proc routeFFIProc*(prc: NimNode): FFIPath {.compileTime.} = ## Reads the receiver and the return type, then names the path. - let params = prc[3] + let params = prc.params let ret = params[0] let hasReceiver = params.len > 1 and isLibReceiver(params[1][1]) @@ -88,7 +82,7 @@ proc assertFFIPath*(prc: NimNode, want: FFIPath) {.compileTime.} = # A receiver is the one mismatch a caller can read straight off the signature. if want == fpStatic and got == fpMethod: error( - "`.ffiStatic.` proc " & name & " takes " & prc[3][1][1].repr & + "`.ffiStatic.` proc " & name & " takes " & prc.params[1][1].repr & " as its first parameter, which is the library type or an {.ffiHandle.} type. " & "A receiver belongs to a context. Make it an `{.ffi.}` method instead." ) diff --git a/ffi/internal/ffi_scalar.nim b/ffi/internal/ffi_scalar.nim index 426b133..de10b12 100644 --- a/ffi/internal/ffi_scalar.nim +++ b/ffi/internal/ffi_scalar.nim @@ -2,20 +2,7 @@ import std/macros import ../codegen/meta - -proc buildLibReadyGuard*( - ctxHandlerName, libTypeName: NimNode -): NimNode {.compileTime.} = - ## Rejects a request that reaches the FFI thread before the ctor stores a - ## library. The guard applies only to a `ref` type. For an `object` type the - ## fallback is a usable zero value, but for a `ref` type it is nil. The guard - ## runs in the handler, behind the ctor in the queue. Thus a host can send a - ## call before it waits for the create callback. - quote: - when `libTypeName` is ref: - if not `ctxHandlerName`[].libReady.load(): - return - err("library is not initialized: the constructor failed or has not run yet") +import ./ffi_codegen_common const scalarPodTypeNames = [ "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", diff --git a/tests/unit/test_ffi_router.nim b/tests/unit/test_ffi_router.nim index adcf782..10bde3d 100644 --- a/tests/unit/test_ffi_router.nim +++ b/tests/unit/test_ffi_router.nim @@ -34,6 +34,21 @@ proc router_version*(): Future[Result[string, string]] {.ffi.} = proc router_alive*(): int {.ffi.} = 7 +# `int` is pointer-wide, so the export must not narrow it to a C `int`. +proc router_big*(): int {.ffi.} = + int(high(int32)) + 1 + +proc router_banner*(): string {.ffi.} = + "router banner" + +var routerBeats = 0 + +proc router_beat*() {.ffi.} = + inc routerBeats + +proc router_raises*(): int {.ffi.} = + raise newException(ValueError, "boom") + # A library receiver and no result, so the router picks the destructor. proc router_destroy*(lib: RouterLib) {.ffi.} = discard @@ -121,7 +136,21 @@ suite "{.ffi.} routes on the shape of the signature": test "no arguments and a plain return type route to the synchronous export": # The export returns its value directly, with no context and no callback. - check router_alive() == cint(7) + check router_alive() == clonglong(7) + + test "an int export keeps its full width across the C ABI": + check router_big() == clonglong(int32.high) + 1 + + test "a string export returns bytes the caller can read": + check $router_banner() == "router banner" + + test "a no-return export routes to a void C symbol": + let before = routerBeats + router_beat() + check routerBeats == before + 1 + + test "an exception in the body never crosses the C ABI": + check router_raises() == clonglong(0) test "a payload parameter and no result route to the event": var s: CallbackState