mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-20 16:29:31 +00:00
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:
parent
c43563f82f
commit
db46bb9aa2
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
@ -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
6
examples/timer/ipc_chronos/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/serve_host
|
||||
/client
|
||||
/libmy_timer.dylib
|
||||
/libmy_timer.so
|
||||
/libmy_timer.dll
|
||||
*.exe
|
||||
81
examples/timer/ipc_chronos/README.md
Normal file
81
examples/timer/ipc_chronos/README.md
Normal 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.
|
||||
118
examples/timer/ipc_chronos/client.nim
Normal file
118
examples/timer/ipc_chronos/client.nim
Normal 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)
|
||||
177
examples/timer/ipc_chronos/serve.nim
Normal file
177
examples/timer/ipc_chronos/serve.nim
Normal 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
|
||||
19
examples/timer/ipc_chronos/serve_host.nim
Normal file
19
examples/timer/ipc_chronos/serve_host.nim
Normal 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)))))
|
||||
@ -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
|
||||
|
||||
@ -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
2
tests/e2e/ipc/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/run_roundtrip
|
||||
*.exe
|
||||
57
tests/e2e/ipc/run_roundtrip.nim
Normal file
57
tests/e2e/ipc/run_roundtrip.nim
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user