rlx-ffi 0.2.18

C ABI for the RLX distributed node — link a mesh worker into iOS, an embedded host, or any non-Rust shell
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
// RLX — versatile ML compiler + runtime.
// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
// SPDX-License-Identifier: MIT OR Apache-2.0

//! C ABI for the RLX **distributed node**.
//!
//! Lets a non-Rust shell join an RLX mesh as a worker rank: an iOS app (link
//! the `staticlib` into an `.xcframework`), an embedded Linux host driving an
//! FPGA board, or anything else that speaks C.
//!
//! The node runs on its own thread — every function here returns immediately,
//! because a serving loop parks in `recv` between activations and a UI thread
//! must not block on it.
//!
//! ```c
//! rlx_node_start(1, 2, "192.168.1.10:29500,192.168.1.11:29501", "auto");
//! char buf[256];
//! rlx_node_status(buf, sizeof buf);   // "running"
//! rlx_node_stop();                     // cooperative
//! ```
//!
//! # Threading
//!
//! All entry points are safe to call from any thread; state is behind a mutex.
//! Only one node may run per process.

use rlx_runtime::dist::node::{
    NodeConfig, NodeControl, NodeStopHandle, serve_trainer_here, serve_worker,
};
use std::ffi::{CStr, CString, c_char, c_int};
use std::sync::{Mutex, OnceLock, mpsc};

/// Result codes. Negative values are errors.
pub const RLX_NODE_OK: c_int = 0;
/// A node is already running in this process.
pub const RLX_NODE_ERR_BUSY: c_int = -1;
/// A pointer argument was null, or a string was not valid UTF-8.
pub const RLX_NODE_ERR_ARG: c_int = -2;
/// `rank`/`world` were inconsistent, or the peer list did not match `world`.
pub const RLX_NODE_ERR_CONFIG: c_int = -3;
/// The serving thread could not be spawned.
pub const RLX_NODE_ERR_SPAWN: c_int = -4;
/// `mode` was not one of the recognised values.
pub const RLX_NODE_ERR_MODE: c_int = -5;

/// Weight/data URIs are resolved **on the node**, so nothing but the spec
/// crosses the wire. The built-in schemes (`file://`, `gguf://`,
/// `safetensors://`) are handled inside `run_train`/`recv_stage`; this is the
/// fallback for anything else, and an empty vector is the honest answer — the
/// caller sees it as a parameter that failed to load rather than as zeros that
/// look like data.
fn uri_resolver(_uri: &str) -> Vec<f32> {
    Vec::new()
}

struct Slot {
    stop: NodeStopHandle,
    done: mpsc::Receiver<String>,
    last: Option<String>,
}

fn slot() -> &'static Mutex<Option<Slot>> {
    static S: OnceLock<Mutex<Option<Slot>>> = OnceLock::new();
    S.get_or_init(|| Mutex::new(None))
}

/// Last error message, so a caller that got a code can read the detail.
fn last_error() -> &'static Mutex<String> {
    static E: OnceLock<Mutex<String>> = OnceLock::new();
    E.get_or_init(|| Mutex::new(String::new()))
}

fn set_error(msg: impl Into<String>) {
    if let Ok(mut e) = last_error().lock() {
        *e = msg.into();
    }
}

/// # Safety
/// `p` must be null or a valid NUL-terminated C string.
unsafe fn cstr(p: *const c_char) -> Option<String> {
    if p.is_null() {
        return None;
    }
    unsafe { CStr::from_ptr(p) }
        .to_str()
        .ok()
        .map(str::to_owned)
}

/// Copy `s` into `buf` (NUL-terminated, truncated to fit). Returns the length
/// written, excluding the terminator, or `RLX_NODE_ERR_ARG`.
///
/// # Safety
/// `buf` must be writable for `cap` bytes.
unsafe fn fill(buf: *mut c_char, cap: usize, s: &str) -> c_int {
    if buf.is_null() || cap == 0 {
        return RLX_NODE_ERR_ARG;
    }
    let Ok(c) = CString::new(s) else {
        return RLX_NODE_ERR_ARG;
    };
    let bytes = c.as_bytes_with_nul();
    let n = bytes.len().min(cap);
    unsafe {
        std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), buf, n);
        // Truncation still leaves a valid C string.
        *buf.add(n - 1) = 0;
    }
    (n - 1) as c_int
}

/// Join a mesh as worker `rank` of `world`.
///
/// * `peers` — comma-separated `host:port` list indexed by rank. Pass an empty
///   string to find the coordinator by UDP broadcast instead.
/// * `device` — `"auto"` or a backend name (`"cpu"`, `"metal"`, `"gpu"`, …).
/// * `mode` — `"infer"` to serve a shipped inference stage, or `"train"` to
///   join a data-parallel training run. `""` means `"infer"`.
///
/// Returns [`RLX_NODE_OK`] once the serving thread is spawned — *not* once the
/// mesh is joined. Poll [`rlx_node_status`] for that.
///
/// # Training and backgrounding
///
/// A training rank cannot drop out partway: the gradient reduce is a barrier,
/// so a node that stops stalls every other rank. [`rlx_node_stop`] is honoured
/// between inference activations but **not** mid-training-run. Start a training
/// node only when the process will stay alive for it.
///
/// # Safety
/// `peers`, `device` and `mode` must be valid NUL-terminated C strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rlx_node_start(
    rank: c_int,
    world: c_int,
    peers: *const c_char,
    device: *const c_char,
    mode: *const c_char,
) -> c_int {
    let (Some(peers), Some(device), Some(mode)) =
        (unsafe { cstr(peers) }, unsafe { cstr(device) }, unsafe {
            cstr(mode)
        })
    else {
        set_error("peers/device/mode must be valid UTF-8 C strings");
        return RLX_NODE_ERR_ARG;
    };
    let training = match mode.as_str() {
        "" | "infer" => false,
        "train" => true,
        other => {
            set_error(format!("unknown mode [{other}]; expected infer or train"));
            return RLX_NODE_ERR_MODE;
        }
    };
    let Ok(mut g) = slot().lock() else {
        set_error("node state poisoned");
        return RLX_NODE_ERR_BUSY;
    };
    if g.as_ref().is_some_and(|s| s.last.is_none()) {
        set_error("a node is already running in this process");
        return RLX_NODE_ERR_BUSY;
    }
    if rank < 0 || world < 1 || rank >= world {
        set_error(format!("bad rank/world: {rank}/{world}"));
        return RLX_NODE_ERR_CONFIG;
    }

    let addrs: Vec<String> = peers
        .split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();

    // Topology follows from what the caller supplied. A phone is a star
    // worker: it dials the coordinator and nothing dials it back (most Wi-Fi
    // will not route inbound to a handset), so a lone address — or discovery,
    // where the coordinator announces itself — means star. A full per-rank
    // list means the caller wants a mesh and believes every rank is reachable.
    let base = NodeConfig::new(rank as u32, world as u32).device(device);
    let cfg = if addrs.is_empty() {
        base.star().discover(29600, 29500)
    } else {
        let star = addrs.len() == 1 && world > 1;
        let base = if star { base.star() } else { base.mesh() };
        match base.peers(addrs) {
            Ok(c) => c,
            Err(e) => {
                set_error(e);
                return RLX_NODE_ERR_CONFIG;
            }
        }
    };

    let ctl = NodeControl::unbounded();
    let stop = ctl.stop_handle();
    let (tx, rx) = mpsc::channel();

    if let Err(e) = std::thread::Builder::new()
        .name("rlx-node".into())
        .spawn(move || {
            let msg = match cfg.connect() {
                Err(e) => format!("error: connect failed: {e}"),
                Ok(group) if training => match serve_trainer_here(&group, uri_resolver, false) {
                    Ok(r) => format!(
                        "ok: rank {} trained on {} ({}), {} sample(s), loss {:.4}->{:.4}",
                        r.rank,
                        r.metrics.device.name(),
                        r.platform,
                        r.metrics.samples,
                        r.metrics.first_loss,
                        r.metrics.last_loss
                    ),
                    Err(e) => format!("error: {e}"),
                },
                Ok(group) => match serve_worker(&group, uri_resolver) {
                    Ok(r) => format!(
                        "ok: rank {} on {} ({}), {} activation(s)",
                        r.rank,
                        r.device.name(),
                        r.platform,
                        r.activations
                    ),
                    Err(e) => format!("error: {e}"),
                },
            };
            let _ = tx.send(msg);
        })
    {
        set_error(format!("spawn: {e}"));
        return RLX_NODE_ERR_SPAWN;
    }

    *g = Some(Slot {
        stop,
        done: rx,
        last: None,
    });
    RLX_NODE_OK
}

/// Write the node state into `buf`: `idle` | `running` | `stopping` |
/// `ok: …` | `error: …`. Returns the length written, or a negative error code.
///
/// # Safety
/// `buf` must be writable for `cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rlx_node_status(buf: *mut c_char, cap: usize) -> c_int {
    let Ok(mut g) = slot().lock() else {
        return unsafe { fill(buf, cap, "error: node state poisoned") };
    };
    let s = match g.as_mut() {
        None => "idle".to_string(),
        Some(s) => {
            if s.last.is_none()
                && let Ok(msg) = s.done.try_recv()
            {
                s.last = Some(msg);
            }
            match &s.last {
                Some(m) => m.clone(),
                None if s.stop.is_stopped() => "stopping".into(),
                None => "running".into(),
            }
        }
    };
    unsafe { fill(buf, cap, &s) }
}

/// Ask the node to leave the mesh after its current activation.
///
/// Cooperative: a node parked in `recv` exits when its peer sends or the link
/// drops, so [`rlx_node_status`] may report `running` briefly afterwards.
#[unsafe(no_mangle)]
pub extern "C" fn rlx_node_stop() -> c_int {
    let Ok(g) = slot().lock() else {
        return RLX_NODE_ERR_BUSY;
    };
    match g.as_ref() {
        Some(s) => {
            s.stop.stop();
            RLX_NODE_OK
        }
        None => RLX_NODE_OK,
    }
}

/// Write the last error detail into `buf`. Returns the length written.
///
/// # Safety
/// `buf` must be writable for `cap` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rlx_node_last_error(buf: *mut c_char, cap: usize) -> c_int {
    let msg = last_error().lock().map(|e| e.clone()).unwrap_or_default();
    unsafe { fill(buf, cap, &msg) }
}

/// Platform tag this library was built for (`ios`, `android`, `linux`, …).
/// Returns a static NUL-terminated string; the caller must not free it.
#[unsafe(no_mangle)]
pub extern "C" fn rlx_node_platform() -> *const c_char {
    // A `const` tag, so one CString per process is enough.
    static P: OnceLock<CString> = OnceLock::new();
    P.get_or_init(|| CString::new(rlx_runtime::dist::node::platform_tag()).unwrap_or_default())
        .as_ptr()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::CString;

    /// Serializes every test that touches the node C ABI.
    ///
    /// The ABI is built on process-global state — one node slot and one
    /// last-error string — which is correct for a C consumer but means a test
    /// that calls `rlx_node_start` and *then* reads `rlx_node_last_error` can
    /// read a sibling's message instead of its own. That is not hypothetical:
    /// `rejects_bad_rank_world` failed with `unknown mode [trian]`, the string
    /// `rejects_an_unknown_mode` had just written. Hold this for any test here
    /// that calls an `rlx_node_*` entry point other than `rlx_node_platform`.
    static FFI_TEST_LOCK: Mutex<()> = Mutex::new(());

    /// Take [`FFI_TEST_LOCK`], ignoring poisoning — it orders calls, and guards
    /// no invariant a panicking test could leave broken.
    fn ffi_lock() -> std::sync::MutexGuard<'static, ()> {
        FFI_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner())
    }

    fn status() -> String {
        let mut buf = [0 as c_char; 256];
        let n = unsafe { rlx_node_status(buf.as_mut_ptr(), buf.len()) };
        assert!(n >= 0, "status returned {n}");
        unsafe { CStr::from_ptr(buf.as_ptr()) }
            .to_string_lossy()
            .into_owned()
    }

    #[test]
    fn rejects_bad_rank_world() {
        let _lock = ffi_lock();
        let peers = CString::new("127.0.0.1:1").unwrap();
        let dev = CString::new("cpu").unwrap();
        // rank >= world
        let mode = CString::new("infer").unwrap();
        let rc = unsafe { rlx_node_start(2, 2, peers.as_ptr(), dev.as_ptr(), mode.as_ptr()) };
        assert_eq!(rc, RLX_NODE_ERR_CONFIG);

        let mut buf = [0 as c_char; 256];
        let n = unsafe { rlx_node_last_error(buf.as_mut_ptr(), buf.len()) };
        assert!(n > 0);
        let msg = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_string_lossy();
        assert!(msg.contains("2/2"), "unexpected error: {msg}");
    }

    #[test]
    fn rejects_peer_count_mismatch() {
        let _lock = ffi_lock();
        // 2 addresses for world 3 — neither a full mesh nor a star's lone
        // coordinator, so it is caught before any socket work.
        let peers = CString::new("127.0.0.1:1,127.0.0.1:2").unwrap();
        let dev = CString::new("cpu").unwrap();
        let mode = CString::new("infer").unwrap();
        let rc = unsafe { rlx_node_start(1, 3, peers.as_ptr(), dev.as_ptr(), mode.as_ptr()) };
        assert_eq!(rc, RLX_NODE_ERR_CONFIG);
    }

    #[test]
    fn rejects_an_unknown_mode() {
        let _lock = ffi_lock();
        let peers = CString::new("127.0.0.1:1").unwrap();
        let dev = CString::new("cpu").unwrap();
        let mode = CString::new("trian").unwrap(); // a plausible typo
        let rc = unsafe { rlx_node_start(0, 1, peers.as_ptr(), dev.as_ptr(), mode.as_ptr()) };
        assert_eq!(rc, RLX_NODE_ERR_MODE);
        let mut buf = [0 as c_char; 256];
        let n = unsafe { rlx_node_last_error(buf.as_mut_ptr(), buf.len()) };
        assert!(n > 0);
        let msg = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_string_lossy();
        assert!(
            msg.contains("trian"),
            "error should name the bad mode: {msg}"
        );
    }

    #[test]
    fn rejects_null_strings() {
        let _lock = ffi_lock();
        let dev = CString::new("cpu").unwrap();
        let mode = CString::new("infer").unwrap();
        let rc = unsafe { rlx_node_start(0, 1, std::ptr::null(), dev.as_ptr(), mode.as_ptr()) };
        assert_eq!(rc, RLX_NODE_ERR_ARG);
    }

    #[test]
    fn status_is_idle_before_start() {
        let _lock = ffi_lock();
        // These tests share one process-global slot. `idle` holds because
        // every start in this module fails validation, and the lock keeps this
        // from observing one mid-flight.
        assert_eq!(status(), "idle");
    }

    #[test]
    fn stop_is_safe_with_no_node() {
        let _lock = ffi_lock();
        assert_eq!(rlx_node_stop(), RLX_NODE_OK);
    }

    #[test]
    fn platform_tag_is_nonempty() {
        let p = rlx_node_platform();
        assert!(!p.is_null());
        let s = unsafe { CStr::from_ptr(p) }.to_string_lossy();
        assert!(!s.is_empty() && s != "unknown", "platform tag: {s}");
    }

    #[test]
    fn status_buffer_truncates_safely() {
        let _lock = ffi_lock();
        let mut buf = [0 as c_char; 3];
        let n = unsafe { rlx_node_status(buf.as_mut_ptr(), buf.len()) };
        assert_eq!(n, 2);
        let s = unsafe { CStr::from_ptr(buf.as_ptr()) }.to_string_lossy();
        assert_eq!(s, "id"); // "idle" truncated, still NUL-terminated
    }
}