Ivan FB db46bb9aa2
feat(examples): in-library chronos CBOR server + cross-platform IPC CI
Adds a standalone IPC example: the library serving itself over a CBOR socket.
examples/timer/ipc_chronos/serve.nim compiles into libmy_timer only under
-d:ffiIpcServe (every other build untouched) and runs a chronos socket server
that, per request, decodes CBOR at the socket edge and calls the library's own
async procs directly — native, in-process, zero serialization between the
socket and the logic, no FFI boundary, no callback bridge. Exposed as
my_timer_serve(address).

CBOR (not the native struct ABI) is correct at the wire here: a relay's data is
serialized regardless, so native would only relocate the decode and add
marshalling for no gain — native locally, CBOR for IPC.

serve_host.nim starts it; client.nim is a lib-free chronos client. Both use
chronos sockets, so the example builds and runs on Linux, macOS and Windows
over TCP (unix sockets are a POSIX bonus).

CI: tests/e2e/ipc/run_roundtrip.nim builds the dylib + host + client, spawns
the server and round-trips over loopback TCP asserting the replies; wired as
`nimble test_ipc` and a 3-OS CI matrix (ubuntu/macos/windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:42:56 +02:00

119 lines
4.1 KiB
Nim

## Cross-platform CBOR-over-socket client for the timer IPC server.
##
## Speaks a simple length-prefixed CBOR framing (see the frame layout below) and uses chronos for
## the socket so it builds and runs on Linux, macOS and Windows. It does not
## link the library — it only needs the CBOR codec to build requests and read
## replies (exactly what a remote client has).
##
## client tcp:127.0.0.1:9099 # any platform
## client unix:/tmp/timer.sock # POSIX
##
## Exits 0 only if the round-trip values match the expectations, so it doubles
## as the integration check the CI driver runs.
import std/[os, strutils]
import chronos
import chronos/transports/stream
import results
import ffi/cbor_serial
# Local mirrors of the wire shapes (a remote client defines its own — it never
# imports the library). Field names match the generated CBOR ABI.
type
EchoReqWire = object
message: string
delayMs: int
EchoReqEnv = object
req: EchoReqWire
EchoRespWire = object
echoed: string
timerName: string
proc writeU32(t: StreamTransport, v: uint32) {.async.} =
let b = @[byte(v shr 24), byte(v shr 16), byte(v shr 8), byte(v and 0xFF)]
discard await t.write(b)
proc readU32(t: StreamTransport): Future[uint32] {.async.} =
var b: array[4, byte]
await t.readExactly(addr b[0], 4)
return
(uint32(b[0]) shl 24) or (uint32(b[1]) shl 16) or (uint32(b[2]) shl 8) or
uint32(b[3])
proc call(
t: StreamTransport, meth: string, payload: seq[byte]
): Future[(int32, seq[byte])] {.async.} =
# Request frame: [u32 method_len][method][u32 payload_len][payload].
await writeU32(t, uint32(meth.len))
if meth.len > 0:
discard await t.write(meth)
await writeU32(t, uint32(payload.len))
if payload.len > 0:
discard await t.write(payload)
# Response frame: [i32 ret][u32 len][body].
let ret = cast[int32](await readU32(t))
let blen = await readU32(t)
var body = newSeq[byte](int(blen))
if blen > 0'u32:
await t.readExactly(addr body[0], int(blen))
return (ret, body)
proc parseAddress(a: string): TransportAddress =
if a.startsWith("unix:"):
return initTAddress(a[5 ..^ 1])
elif a.startsWith("tcp:"):
let hp = a[4 ..^ 1]
let sep = hp.rfind(':')
doAssert sep > 0, "tcp address must be tcp:<host>:<port>"
return initTAddress(hp[0 ..< sep], Port(parseInt(hp[sep + 1 ..^ 1])))
else:
raise newException(ValueError, "address must be unix:<path> or tcp:<host>:<port>")
proc connectWithRetry(ta: TransportAddress): Future[StreamTransport] {.async.} =
# The server may still be starting; retry briefly so the example/CI is not
# racing socket setup.
for _ in 0 ..< 100:
try:
return await connect(ta)
except CatchableError:
await sleepAsync(50.milliseconds)
return await connect(ta) # last attempt: surface the real error
proc run(address: string): Future[bool] {.async.} =
let t = await connectWithRetry(parseAddress(address))
var ok = true
echo "[client] connected"
# 1) version — empty request, response is a CBOR text string.
block:
let (ret, body) = await t.call("version", @[byte 0xA0]) # empty CBOR map {}
let v = cborDecode(body, string)
if ret == 0 and v.isOk:
echo "[client] version = ", v.get()
ok = ok and v.get() == "nim-timer v0.1.0"
else:
echo "[client] version failed (ret=", ret, ")"
ok = false
# 2) echo — nested request, response is an EchoResponse map.
block:
let req = cborEncode(EchoReqEnv(req: EchoReqWire(message: "hello over the wire", delayMs: 5)))
let (ret, body) = await t.call("echo", req)
let r = cborDecode(body, EchoRespWire)
if ret == 0 and r.isOk:
echo "[client] echo.echoed= ", r.get().echoed
echo "[client] echo.timer = ", r.get().timerName
ok = ok and r.get().echoed == "hello over the wire" and r.get().timerName == "ipc-server"
else:
echo "[client] echo failed (ret=", ret, ")"
ok = false
await t.closeWait()
echo "[client] done"
return ok
when isMainModule:
if paramCount() != 1:
stderr.writeLine "usage: client <tcp:host:port | unix:path>"
quit(2)
quit(if waitFor run(paramStr(1)): 0 else: 1)