From db46bb9aa22a3ab49ef81c7777d1651eb025c613 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Mon, 1 Jun 2026 08:42:56 +0200 Subject: [PATCH] feat(examples): in-library chronos CBOR server + cross-platform IPC CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 67 ++++++++ examples/timer/ipc_chronos/.gitignore | 6 + examples/timer/ipc_chronos/README.md | 81 ++++++++++ examples/timer/ipc_chronos/client.nim | 118 +++++++++++++++ examples/timer/ipc_chronos/serve.nim | 177 ++++++++++++++++++++++ examples/timer/ipc_chronos/serve_host.nim | 19 +++ examples/timer/timer.nim | 6 + ffi.nimble | 6 + tests/e2e/ipc/.gitignore | 2 + tests/e2e/ipc/run_roundtrip.nim | 57 +++++++ 10 files changed, 539 insertions(+) create mode 100644 examples/timer/ipc_chronos/.gitignore create mode 100644 examples/timer/ipc_chronos/README.md create mode 100644 examples/timer/ipc_chronos/client.nim create mode 100644 examples/timer/ipc_chronos/serve.nim create mode 100644 examples/timer/ipc_chronos/serve_host.nim create mode 100644 tests/e2e/ipc/.gitignore create mode 100644 tests/e2e/ipc/run_roundtrip.nim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c39cdf..fcc98f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/examples/timer/ipc_chronos/.gitignore b/examples/timer/ipc_chronos/.gitignore new file mode 100644 index 0000000..9f072ec --- /dev/null +++ b/examples/timer/ipc_chronos/.gitignore @@ -0,0 +1,6 @@ +/serve_host +/client +/libmy_timer.dylib +/libmy_timer.so +/libmy_timer.dll +*.exe diff --git a/examples/timer/ipc_chronos/README.md b/examples/timer/ipc_chronos/README.md new file mode 100644 index 0000000..8ab3b72 --- /dev/null +++ b/examples/timer/ipc_chronos/README.md @@ -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:` 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. diff --git a/examples/timer/ipc_chronos/client.nim b/examples/timer/ipc_chronos/client.nim new file mode 100644 index 0000000..b6cad02 --- /dev/null +++ b/examples/timer/ipc_chronos/client.nim @@ -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::" + return initTAddress(hp[0 ..< sep], Port(parseInt(hp[sep + 1 ..^ 1]))) + else: + raise newException(ValueError, "address must be unix: or tcp::") + +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 " + quit(2) + quit(if waitFor run(paramStr(1)): 0 else: 1) diff --git a/examples/timer/ipc_chronos/serve.nim b/examples/timer/ipc_chronos/serve.nim new file mode 100644 index 0000000..75ba673 --- /dev/null +++ b/examples/timer/ipc_chronos/serve.nim @@ -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::" + server = + createStreamServer( + initTAddress(hp[0 ..< sep], Port(parseInt(hp[sep + 1 ..^ 1]))), + onConnection, + {ReuseAddr}, + ) + else: + raise newException(ValueError, "address must be unix: or tcp::") + 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:" or "tcp::". + initializeLibrary() + initEventRegistry(serveEventRegistry) + ffiCurrentEventRegistry = addr serveEventRegistry + try: + waitFor serveLoop($address) + except CatchableError as e: + echo "[serve] error: ", e.msg + return 1 + return 0 diff --git a/examples/timer/ipc_chronos/serve_host.nim b/examples/timer/ipc_chronos/serve_host.nim new file mode 100644 index 0000000..02e3682 --- /dev/null +++ b/examples/timer/ipc_chronos/serve_host.nim @@ -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 " + quit(2) + quit(int(my_timer_serve(cstring(paramStr(1))))) diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index 5b119de..14e6217 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -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 diff --git a/ffi.nimble b/ffi.nimble index d52a8ac..6165c9b 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -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" diff --git a/tests/e2e/ipc/.gitignore b/tests/e2e/ipc/.gitignore new file mode 100644 index 0000000..6c7644b --- /dev/null +++ b/tests/e2e/ipc/.gitignore @@ -0,0 +1,2 @@ +/run_roundtrip +*.exe diff --git a/tests/e2e/ipc/run_roundtrip.nim b/tests/e2e/ipc/run_roundtrip.nim new file mode 100644 index 0000000..0a1e377 --- /dev/null +++ b/tests/e2e/ipc/run_roundtrip.nim @@ -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"