diff --git a/ffi/ffi_thread_request.nim b/ffi/ffi_thread_request.nim index 9d0e863..f1cb2c5 100644 --- a/ffi/ffi_thread_request.nim +++ b/ffi/ffi_thread_request.nim @@ -177,9 +177,9 @@ func ffiScalarRetBytes*[T](x: T): seq[byte] = ## Serializes a scalar handler result into the raw response payload the ## callback carries — no CBOR envelope. A `string`/`cstring` rides as its ## own UTF-8 bytes (like the error path); every other scalar rides as the - ## 8-byte native-endian image of `ffiPackScalar(x)`. Note: an empty string - ## yields a 0-length payload, which `handleRes` sends as the CBOR-null - ## sentinel — the foreign scalar reader (a follow-up) must special-case it. + ## 8-byte native-endian image of `ffiPackScalar(x)`. An empty string yields a + ## 0-length payload, which `handleRes` delivers as a genuine 0-length + ## `RET_OK` buffer (not the CBOR-null sentinel), so it reads back as "". when T is string: var b = newSeq[byte](x.len) if x.len > 0: @@ -231,6 +231,13 @@ proc fireCallback*(res: Result[seq[byte], string], request: ptr FFIThreadRequest cast[csize_t](bytes.len), request[].userData, ) + elif request[].isScalar: + # A scalar return of 0 bytes is a real empty string, not "no value": + # hand back a genuine empty buffer, not the CBOR-null sentinel. + var empty: byte + request[].callback( + RET_OK, cast[ptr cchar](addr empty), 0.csize_t, request[].userData + ) else: # Always hand the callback a real buffer; CBOR null marks "no value". var sentinel = CborNullByte diff --git a/tests/unit/test_scalar_fastpath.nim b/tests/unit/test_scalar_fastpath.nim index 750054b..8a4a47c 100644 --- a/tests/unit/test_scalar_fastpath.nim +++ b/tests/unit/test_scalar_fastpath.nim @@ -61,7 +61,14 @@ proc scalarfast_checked*( return err("negative not allowed") return ok(n * 2) -## --- C-shape callback harness (mirrors test_ffi_context.nim) ---------------- +proc scalarfast_blank*( + lib: ScalarLib +): Future[Result[string, string]] {.ffi: "abi = c".} = + ## Empty-string return — must ride back as a genuine 0-length RET_OK payload, + ## not the CBOR-null sentinel. + return ok("") + +## C-shape callback harness (mirrors test_ffi_context.nim). type CallbackData = object lock: Lock @@ -122,10 +129,7 @@ proc scalarStr(d: CallbackData): string = s proc callbackErr(d: CallbackData): string = - var msg = newString(d.msgLen) - if d.msgLen > 0: - copyMem(addr msg[0], unsafeAddr d.msg[0], d.msgLen) - msg + scalarStr(d) proc encodedPtr(bytes: var seq[byte]): ptr byte = if bytes.len == 0: @@ -183,6 +187,22 @@ suite "scalar fast path — C export shape": check d.retCode == RET_OK check scalarStr(d) == "scalarfast v1" + test "empty string return rides back as a real 0-length RET_OK payload": + let ctx = makeCtx(0) + defer: + check ScalarLibFFIPool.destroyFFIContext(ctx).isOk() + + var d: CallbackData + initCallbackData(d) + defer: + deinitCallbackData(d) + + check scalarfast_blank(ctx, testCallback, addr d) == RET_OK + waitCallback(d) + check d.retCode == RET_OK + check d.msgLen == 0 # NOT 1 (would be the 0xf6 sentinel) + check scalarStr(d) == "" + test "float param round-trips through the uint64 slot": let ctx = makeCtx(0) defer: @@ -250,13 +270,17 @@ suite "scalar fast path — Nim-native shape": check bad.isErr() check bad.error == "negative not allowed" + let blank = waitFor scalarfast_blank(ScalarLib(base: 0)) + check blank.isOk() + check blank.value == "" + # `ffiProcRegistry` is a compile-time var, so its assertions run in a static # block (mirrors test_abi_format.nim). A scalar-only `abi = c` proc must be # flagged, recognised by `isScalarOnly`, and dropped from `bindableProcs`. static: const scalarNames = [ "scalarfast_add", "scalarfast_version", "scalarfast_scale", "scalarfast_positive", - "scalarfast_checked", + "scalarfast_checked", "scalarfast_blank", ] var seen = 0 for p in ffiProcRegistry: