feat: empty-string scalar return

This commit is contained in:
Gabriel Cruz 2026-07-01 16:50:03 -03:00
parent 8dcdcdc76a
commit 8cbe1cb82f
No known key found for this signature in database
GPG Key ID: 3C6977037D5A1EF5
2 changed files with 40 additions and 9 deletions

View File

@ -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

View File

@ -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: