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>
This commit is contained in:
Ivan FB 2026-06-01 08:42:56 +02:00
parent c43563f82f
commit db46bb9aa2
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
10 changed files with 539 additions and 0 deletions

View File

@ -208,6 +208,73 @@ jobs:
nim-versions: ${{ needs.versions.outputs.nim-versions }}
nimble-version: ${{ needs.versions.outputs.nimble }}
ipc:
# In-library CBOR serve over a socket — must build and round-trip on all
# three platforms (TCP is the portable transport). fail-fast:false keeps
# Linux/macOS results visible if Windows trips on the dylib link.
needs: versions
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-latest]
include:
- os: ubuntu-22.04
label: Linux
- os: macos-15
label: macOS
- os: windows-latest
label: Windows
runs-on: ${{ matrix.os }}
name: IPC round-trip · ${{ matrix.label }}
env:
NIMBLE_VERSION: ${{ needs.versions.outputs.nimble }}
steps:
- uses: actions/checkout@v4
- name: Setup Nim
uses: jiro4989/setup-nim-action@v2
with:
nim-version: "2.2.4"
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nimble ${{ env.NIMBLE_VERSION }}
shell: bash
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
export PATH="$GITHUB_WORKSPACE/.nim_runtime/bin:$PATH"
fi
cd /tmp && nimble install "nimble@${{ env.NIMBLE_VERSION }}" -y
echo "$HOME/.nimble/bin" >> $GITHUB_PATH
- name: Cache nimble deps
id: cache-nimbledeps
uses: actions/cache@v4
with:
path: |
nimbledeps/
nimble.paths
key: ${{ runner.os }}-nimbledeps-2.2.4-${{ hashFiles('*.nimble') }}
restore-keys: |
${{ runner.os }}-nimbledeps-2.2.4-
${{ runner.os }}-nimbledeps-
- name: Install nimble deps
if: steps.cache-nimbledeps.outputs.cache-hit != 'true'
shell: bash
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
export PATH="$GITHUB_WORKSPACE/.nim_runtime/bin:$HOME/.nimble/bin:$PATH"
fi
nimble setup --localdeps -y
- name: IPC round-trip
shell: bash
run: |
if [ "$RUNNER_OS" == "Windows" ]; then
export PATH="$GITHUB_WORKSPACE/.nim_runtime/bin:$HOME/.nimble/bin:$PATH"
fi
nimble test_ipc -y
auto-assign:
name: Auto-assign PR author
if: github.event_name == 'pull_request' && github.event.action == 'opened'

6
examples/timer/ipc_chronos/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/serve_host
/client
/libmy_timer.dylib
/libmy_timer.so
/libmy_timer.dll
*.exe

View File

@ -0,0 +1,81 @@
# IPC example — the library serves *itself* over CBOR (chronos)
When a caller lives in the **same process** as the library it uses the native
ABI (zero serialization). When it lives in a **different process** — possibly on
a different machine — there is no shared address space, so requests must be
serialized. That is what **CBOR** is for: *native locally, CBOR for IPC.*
This example puts the socket server **inside the library**. With
`-d:ffiIpcServe`, `libmy_timer` gains `serve.nim`, which:
- runs a chronos socket server, and
- for each request, **decodes CBOR at the socket edge and calls the library's
own async procs directly** — a plain in-process call, native, with no
serialization between the socket layer and the logic, and no FFI boundary or
callback bridge inside the server. The server *is* the library.
```
remote client ──CBOR over socket──▶ serve loop ──direct Nim call──▶ timer procs
(inter-process) (in-process, zero-serialization)
```
It speaks CBOR (not the native struct ABI) at the wire because over a socket the
data is serialized regardless — a native ABI would only move the decode and add
marshalling for no gain. CBOR-on-the-wire / direct-call-in-process is the right
shape for a relay.
## Files
| File | Role |
|------|------|
| `serve.nim` | Compiled into `libmy_timer` under `-d:ffiIpcServe`; the chronos server + `my_timer_serve(address)`. |
| `serve_host.nim` | Tiny host that links the library and starts the server. |
| `client.nim` | Lib-free chronos client (builds CBOR requests, reads replies). |
The wire framing (network byte order, so endianness never matters):
```
request: [u32 method_len][method][u32 payload_len][cbor payload]
response: [i32 ret ][u32 resp_len][cbor response]
```
## Run
It builds and runs on **Linux, macOS and Windows** (TCP is the portable
transport; `unix:<path>` also works on POSIX).
```sh
# one-command, asserted round-trip over loopback TCP (this is what CI runs):
nimble test_ipc
```
Or by hand, from the repo root:
```sh
ext=$(case "$(uname -s)" in Darwin) echo dylib;; *) echo so;; esac)
nim c --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiIpcServe \
-o:examples/timer/ipc_chronos/libmy_timer.$ext examples/timer/timer.nim
nim c --passL:-Lexamples/timer/ipc_chronos --passL:-lmy_timer \
--passL:-Wl,-rpath,"$PWD/examples/timer/ipc_chronos" \
-o:examples/timer/ipc_chronos/serve_host examples/timer/ipc_chronos/serve_host.nim
nim c -o:examples/timer/ipc_chronos/client examples/timer/ipc_chronos/client.nim
examples/timer/ipc_chronos/serve_host tcp:127.0.0.1:9099 &
examples/timer/ipc_chronos/client tcp:127.0.0.1:9099
```
Expected client output:
```
[client] version = nim-timer v0.1.0
[client] echo.echoed= hello over the wire
[client] echo.timer = ipc-server # the server's own context state round-tripped
```
## Notes
- The serve loop fires the library's events (e.g. `echo``onEchoFired`); it
installs an empty event registry on its thread so dispatch finds zero
listeners. Delivering events to remote clients is separate, future work.
- A remote client needs only a CBOR codec, not the compiled library — it can be
written in any language. `client.nim` is the Nim reference.

View File

@ -0,0 +1,118 @@
## 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)

View File

@ -0,0 +1,177 @@
## In-library CBOR-over-socket server — the *remote channel* of the
## in-process/remote split.
##
## Included into `timer.nim` only under `-d:ffiIpcServe`, so it shares the
## module scope: it can build a `MyTimer` and call the library's async procs
## (`myTimerEcho`, …) **directly** — a plain Nim call, native, in-process, with
## zero serialization between the socket layer and the logic. CBOR exists only
## at the socket edge: each request is decoded, dispatched to the matching proc,
## and the result re-encoded. There is no FFI boundary and no callback bridge
## inside the server because the server *is* the library.
##
## Exposed as `my_timer_serve(address)` (C ABI) so a trivial host can start it.
## The wire framing is the simple length-prefixed format defined below (and
## mirrored by `client.nim`); a remote client needs only a CBOR codec.
import std/[os, strutils]
import chronos
import chronos/transports/stream
import results
import ffi/cbor_serial
import ffi/ffi_events
# The async procs fire library events (e.g. echo -> onEchoFired). Off the FFI
# thread that would log "event registry not set"; give this thread an empty
# registry so dispatch finds zero listeners and stays quiet. Delivering events
# to remote clients is separate, future work.
var serveEventRegistry: FFIEventRegistry
# Wire request envelopes. The field names mirror the generated CBOR request
# ABI (each `{.ffi.}` proc packs its args under their Nim parameter names), so
# the same bytes the C client builds decode straight into these.
type
EchoReqEnv = object
req: EchoRequest
ComplexReqEnv = object
req: ComplexRequest
ScheduleReqEnv = object
job: JobSpec
retry: RetryPolicy
schedule: ScheduleConfig
const
RetOk: int32 = 0 # mirrors RET_OK / RET_ERR in my_timer_cbor.h
RetErr: int32 = 1
proc toBytes(s: string): seq[byte] =
var b = newSeq[byte](s.len)
if s.len > 0:
copyMem(addr b[0], unsafeAddr s[0], s.len)
return b
proc toStr(b: openArray[byte]): string =
var s = newString(b.len)
if b.len > 0:
copyMem(addr s[0], unsafeAddr b[0], b.len)
return s
proc readU32(t: StreamTransport): Future[uint32] {.async.} =
# Frame integers are network byte order (big-endian); see the frame layout below.
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 writeResponse(t: StreamTransport, ret: int32, body: seq[byte]) {.async.} =
# Response frame: [i32 ret][u32 len][body], big-endian.
var hdr = newSeq[byte](8)
let r = cast[uint32](ret)
hdr[0] = byte(r shr 24)
hdr[1] = byte(r shr 16)
hdr[2] = byte(r shr 8)
hdr[3] = byte(r and 0xFF)
let l = uint32(body.len)
hdr[4] = byte(l shr 24)
hdr[5] = byte(l shr 16)
hdr[6] = byte(l shr 8)
hdr[7] = byte(l and 0xFF)
discard await t.write(hdr)
if body.len > 0:
discard await t.write(body)
proc dispatch(meth: string, payload: seq[byte]): Future[(int32, seq[byte])] {.async.} =
# The library's own state object — no FFIContext needed, we call the async
# procs directly. `MyTimer` only carries a name, so building it per request
# is free and keeps the handler stateless.
let timer = MyTimer(name: "ipc-server")
case meth
of "version":
let r = await myTimerVersion(timer)
if r.isOk:
return (RetOk, cborEncode(r.get())) # string -> CBOR text
return (RetErr, toBytes(r.error()))
of "echo":
let env = cborDecode(payload, EchoReqEnv)
if env.isErr:
return (RetErr, toBytes(env.error()))
let r = await myTimerEcho(timer, env.get().req)
if r.isOk:
return (RetOk, cborEncode(r.get()))
return (RetErr, toBytes(r.error()))
of "complex":
let env = cborDecode(payload, ComplexReqEnv)
if env.isErr:
return (RetErr, toBytes(env.error()))
let r = await myTimerComplex(timer, env.get().req)
if r.isOk:
return (RetOk, cborEncode(r.get()))
return (RetErr, toBytes(r.error()))
of "schedule":
let env = cborDecode(payload, ScheduleReqEnv)
if env.isErr:
return (RetErr, toBytes(env.error()))
let r =
await myTimerSchedule(timer, env.get().job, env.get().retry, env.get().schedule)
if r.isOk:
return (RetOk, cborEncode(r.get()))
return (RetErr, toBytes(r.error()))
else:
return (RetErr, toBytes("unknown method: " & meth))
proc onConnection(
server: StreamServer, transp: StreamTransport
) {.async: (raises: []).} =
try:
while not transp.atEof():
let mlen = await readU32(transp)
var mbytes = newSeq[byte](int(mlen))
if mlen > 0'u32:
await transp.readExactly(addr mbytes[0], int(mlen))
let plen = await readU32(transp)
var payload = newSeq[byte](int(plen))
if plen > 0'u32:
await transp.readExactly(addr payload[0], int(plen))
let (ret, body) = await dispatch(toStr(mbytes), payload)
await writeResponse(transp, ret, body)
except CatchableError:
discard # peer closed or malformed frame: drop the connection
try:
await transp.closeWait()
except CatchableError:
discard
proc serveLoop(address: string) {.async.} =
var server: StreamServer
if address.startsWith("unix:"):
let path = address[5 ..^ 1]
removeFile(path) # clear a stale socket so bind() succeeds
server = createStreamServer(initTAddress(path), onConnection, {ReuseAddr})
elif address.startsWith("tcp:"):
let hp = address[4 ..^ 1]
let sep = hp.rfind(':')
doAssert sep > 0, "tcp address must be tcp:<host>:<port>"
server =
createStreamServer(
initTAddress(hp[0 ..< sep], Port(parseInt(hp[sep + 1 ..^ 1]))),
onConnection,
{ReuseAddr},
)
else:
raise newException(ValueError, "address must be unix:<path> or tcp:<host>:<port>")
server.start()
echo "[serve] listening on ", address
await server.join()
proc my_timer_serve(address: cstring): cint {.exportc, cdecl, dynlib.} =
## C entry point: boot the library runtime and run the socket server forever.
## Blocks the calling thread. `address` is "unix:<path>" or "tcp:<host>:<port>".
initializeLibrary()
initEventRegistry(serveEventRegistry)
ffiCurrentEventRegistry = addr serveEventRegistry
try:
waitFor serveLoop($address)
except CatchableError as e:
echo "[serve] error: ", e.msg
return 1
return 0

View File

@ -0,0 +1,19 @@
## Cross-platform host for the in-library CBOR server.
##
## The library *is* the server: `my_timer_serve` (compiled into libmy_timer with
## -d:ffiIpcServe) runs the chronos socket loop and dispatches each decoded
## request to the library's own procs directly. This host just links the lib and
## starts it. Written in Nim so the dylib link is handled portably (the C host
## `serve_host.c` is the POSIX-only equivalent).
##
## serve_host tcp:0.0.0.0:9099 # any platform
## serve_host unix:/tmp/timer.sock # POSIX
import std/os
proc my_timer_serve(address: cstring): cint {.importc, cdecl.}
when isMainModule:
if paramCount() != 1:
stderr.writeLine "usage: serve_host <tcp:host:port | unix:path>"
quit(2)
quit(int(my_timer_serve(cstring(paramStr(1)))))

View File

@ -138,6 +138,12 @@ proc my_timer_destroy*(timer: MyTimer) {.ffiDtor.} =
## Blocks until the FFI thread and watchdog thread have joined.
discard
# Optional in-library CBOR-over-socket server (the "remote channel"). Compiled
# in only with -d:ffiIpcServe so every other build is unaffected; it reuses the
# async procs above directly. See examples/timer/ipc_chronos/serve.nim.
when defined(ffiIpcServe):
include "ipc_chronos/serve.nim"
# genBindings() must be the LAST top-level call in the FFI root file —
# after every {.ffi.}, {.ffiCtor.} and {.ffiDtor.} pragma. Each pragma
# fires at compile time and registers its proc into the compile-time

View File

@ -90,6 +90,12 @@ task test_serial, "Run CBOR codec unit tests":
exec "nim c -r " & nimFlagsOrc & " tests/unit/test_serial.nim"
exec "nim c -r " & nimFlagsRefc & " tests/unit/test_serial.nim"
task test_ipc, "Cross-platform IPC round-trip: in-library CBOR serve over TCP":
# Compiled driver (Linux/macOS/Windows) builds the dylib + Nim host + Nim
# client and round-trips over a loopback socket, asserting the replies. It is
# a compiled program (not NimScript) because it must spawn the server process.
runOrQuit "nim c -r " & nimFlagsOrc & " tests/e2e/ipc/run_roundtrip.nim"
task test_cpp_e2e, "Build and run the C++ end-to-end tests for the timer example":
# Regenerate the C++ bindings so the suite always runs against fresh codegen.
runOrQuit "nimble genbindings_cpp"

2
tests/e2e/ipc/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/run_roundtrip
*.exe

View File

@ -0,0 +1,57 @@
## Cross-platform IPC round-trip integration test (Linux / macOS / Windows).
##
## Builds the timer library with the in-process CBOR serve loop (-d:ffiIpcServe),
## the Nim host that starts it, and the lib-free Nim client; then starts the
## server, runs the client over a loopback TCP socket, and asserts the client's
## exit code. Everything uses chronos / Nim tooling, so there is no POSIX-only
## C or shell in the path. Driven by `nimble test_ipc`.
import std/[os, osproc, strutils]
const
Port = 47097
Address = "tcp:127.0.0.1:" & $Port
let
root = getCurrentDir() # nimble runs tasks from the repo root
ipc = root / "examples" / "timer" / "ipc_chronos"
libExt =
when defined(windows): "dll" elif defined(macosx): "dylib" else: "so"
exeExt = (when defined(windows): ".exe" else: "")
lib = ipc / ("libmy_timer." & libExt)
serveHost = ipc / ("serve_host" & exeExt)
client = ipc / ("client" & exeExt)
nimBase = "nim c --mm:orc -d:chronicles_log_level=WARN"
proc sh(cmd: string) =
echo "+ ", cmd
let code = execCmd(cmd)
doAssert code == 0, "command failed (" & $code & "): " & cmd
# 1) Library with the serve loop compiled in.
sh nimBase & " --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiIpcServe" & " -o:" &
lib.quoteShell & " " & (root / "examples" / "timer" / "timer.nim").quoteShell
# 2) Host links the lib (rpath so it is found next to the binary on POSIX; on
# Windows the dll sits in the same directory and is found automatically).
var hostCmd =
nimBase & " --passL:-L" & ipc.quoteShell & " --passL:-lmy_timer"
when not defined(windows):
hostCmd &= " --passL:-Wl,-rpath," & ipc.quoteShell
sh hostCmd & " -o:" & serveHost.quoteShell & " " & (ipc / "serve_host.nim").quoteShell
# 3) Client (no lib).
sh nimBase & " -o:" & client.quoteShell & " " & (ipc / "client.nim").quoteShell
# 4) Start the server, run the client (it retries the connect), assert, stop.
echo "+ start ", serveHost, " ", Address
let srv = startProcess(serveHost, args = [Address], options = {poParentStreams})
var clientCode = 1
try:
clientCode = execCmd(client.quoteShell & " " & Address)
finally:
srv.terminate()
discard srv.waitForExit(2000)
srv.close()
doAssert clientCode == 0, "client round-trip failed (exit " & $clientCode & ")"
echo "IPC cross-platform round-trip OK"