Skip to main content

pamoja_ffi/
loopback.rs

1//! The C ABI for the in-process loopback broker.
2//!
3//! These functions wrap [`pamoja_loopback`] so a caller can exercise the
4//! publish-and-subscribe path with no broker, no network, and no hardware. That
5//! matters most for the bindings: someone writing against the SDK from Python or
6//! C# can drive a whole message flow in a unit test rather than standing up
7//! infrastructure to find out whether their topics line up.
8//!
9//! A broker is shared by cloning, so every transport built from one sees the
10//! same traffic. Pair it with
11//! [`pamoja_transport_faulty`](crate::transport::pamoja_transport_faulty) to
12//! check what a caller does when a link starts refusing sends.
13
14use std::ffi::c_char;
15use std::ptr;
16use std::sync::Arc;
17
18use pamoja_core::{Receive, Transport};
19use pamoja_loopback::{LoopbackBroker, LoopbackTransport};
20use tokio::sync::Mutex;
21
22use crate::transport::{receive_within, status, Kind, PamojaMessage, PamojaTransport};
23use crate::{read_bytes, read_str, runtime, set_last_error, PamojaStatus};
24
25/// An opaque handle to an in-process broker.
26///
27/// Every transport built from one broker shares its traffic, so a message one
28/// publishes reaches the others that subscribed to the topic.
29pub struct PamojaLoopbackBroker {
30    inner: LoopbackBroker,
31}
32
33/// An opaque handle to one in-process link to a broker.
34pub struct PamojaLoopbackTransport {
35    inner: Arc<Mutex<LoopbackTransport>>,
36}
37
38/// Creates an in-process broker with no traffic.
39///
40/// # Returns
41///
42/// A handle the caller must release with [`pamoja_loopback_broker_free`].
43#[no_mangle]
44pub extern "C" fn pamoja_loopback_broker_new() -> *mut PamojaLoopbackBroker {
45    Box::into_raw(Box::new(PamojaLoopbackBroker {
46        inner: LoopbackBroker::new(),
47    }))
48}
49
50/// Takes a broker out of reach, or brings it back.
51///
52/// Out of reach, every link on the broker fails to connect, send, or subscribe
53/// with [`PamojaStatus::Transport`], as an out-of-range radio would, and nothing
54/// is delivered. Links keep their connections and filters through the outage,
55/// including links a ladder owns, so they carry traffic again once it ends.
56///
57/// # Arguments
58///
59/// * `broker` - the broker.
60/// * `reachable` - `false` to take it out of reach, `true` to bring it back.
61///
62/// # Returns
63///
64/// [`PamojaStatus::Ok`], or [`PamojaStatus::InvalidArgument`] if `broker` is
65/// null.
66///
67/// # Safety
68///
69/// `broker` must be a live handle from [`pamoja_loopback_broker_new`], or null.
70#[no_mangle]
71pub unsafe extern "C" fn pamoja_loopback_broker_set_reachable(
72    broker: *const PamojaLoopbackBroker,
73    reachable: bool,
74) -> PamojaStatus {
75    match broker_handle(broker) {
76        Some(broker) => {
77            broker.inner.set_reachable(reachable);
78            PamojaStatus::Ok
79        }
80        None => PamojaStatus::InvalidArgument,
81    }
82}
83
84/// Reports whether a broker is in reach.
85///
86/// # Arguments
87///
88/// * `broker` - the broker.
89///
90/// # Returns
91///
92/// `true` unless it was taken out of reach, and `false` if `broker` is null.
93///
94/// # Safety
95///
96/// `broker` must be a live handle from [`pamoja_loopback_broker_new`], or null.
97#[no_mangle]
98pub unsafe extern "C" fn pamoja_loopback_broker_is_reachable(
99    broker: *const PamojaLoopbackBroker,
100) -> bool {
101    broker_handle(broker).is_some_and(|broker| broker.inner.is_reachable())
102}
103
104/// Releases a broker handle.
105///
106/// Transports already built from the broker keep working, because each holds
107/// its own share of it.
108///
109/// Passing null is a no-op.
110///
111/// # Safety
112///
113/// `broker` must be a handle from [`pamoja_loopback_broker_new`] that has not
114/// already been freed, or null. After this call it must not be used again.
115#[no_mangle]
116pub unsafe extern "C" fn pamoja_loopback_broker_free(broker: *mut PamojaLoopbackBroker) {
117    if !broker.is_null() {
118        drop(Box::from_raw(broker));
119    }
120}
121
122/// Creates a link to a broker.
123///
124/// # Arguments
125///
126/// * `broker` - the broker to join. It is shared, not consumed, so the same
127///   broker can back as many links as the caller needs.
128///
129/// # Returns
130///
131/// A handle the caller must release with
132/// [`pamoja_loopback_transport_free`], or null if `broker` is null.
133///
134/// # Safety
135///
136/// `broker` must be a live handle from [`pamoja_loopback_broker_new`], or null.
137#[no_mangle]
138pub unsafe extern "C" fn pamoja_loopback_transport_new(
139    broker: *const PamojaLoopbackBroker,
140) -> *mut PamojaLoopbackTransport {
141    let Some(broker) = broker_handle(broker) else {
142        return ptr::null_mut();
143    };
144    Box::into_raw(Box::new(PamojaLoopbackTransport {
145        inner: Arc::new(Mutex::new(LoopbackTransport::new(broker.inner.clone()))),
146    }))
147}
148
149/// Marks a link connected so it will carry traffic.
150///
151/// # Arguments
152///
153/// * `transport` - the link.
154///
155/// # Returns
156///
157/// [`PamojaStatus::Ok`] once connected.
158///
159/// # Safety
160///
161/// `transport` must be a live handle from [`pamoja_loopback_transport_new`].
162#[no_mangle]
163pub unsafe extern "C" fn pamoja_loopback_transport_connect(
164    transport: *mut PamojaLoopbackTransport,
165) -> PamojaStatus {
166    let Some(transport) = transport_handle(transport) else {
167        return PamojaStatus::InvalidArgument;
168    };
169    let inner = Arc::clone(&transport.inner);
170    status(runtime().block_on(async move { inner.lock().await.connect().await }))
171}
172
173/// Publishes a payload to a topic on the broker.
174///
175/// # Arguments
176///
177/// * `transport` - the link.
178/// * `topic` - the destination topic, as null-terminated UTF-8.
179/// * `payload` - the bytes to publish.
180/// * `payload_len` - the length of `payload`.
181///
182/// # Returns
183///
184/// [`PamojaStatus::Ok`] once every subscriber has been handed the message.
185///
186/// # Safety
187///
188/// `transport` must be a live handle, `topic` a valid null-terminated UTF-8
189/// string, and `payload` must point to at least `payload_len` readable bytes or
190/// be null when that length is 0.
191#[no_mangle]
192pub unsafe extern "C" fn pamoja_loopback_transport_send(
193    transport: *mut PamojaLoopbackTransport,
194    topic: *const c_char,
195    payload: *const u8,
196    payload_len: usize,
197) -> PamojaStatus {
198    let Some(transport) = transport_handle(transport) else {
199        return PamojaStatus::InvalidArgument;
200    };
201    let Some(topic) = read_str(topic, "topic") else {
202        return PamojaStatus::InvalidArgument;
203    };
204    let payload = match read_bytes(payload, payload_len) {
205        Ok(payload) => payload,
206        Err(status) => return status,
207    };
208    let inner = Arc::clone(&transport.inner);
209    let topic = topic.to_owned();
210    status(runtime().block_on(async move { inner.lock().await.send(&topic, &payload).await }))
211}
212
213/// Subscribes a link to a topic.
214///
215/// # Arguments
216///
217/// * `transport` - the link.
218/// * `topic` - the topic to subscribe to, as null-terminated UTF-8.
219///
220/// # Returns
221///
222/// [`PamojaStatus::Ok`] once subscribed.
223///
224/// # Safety
225///
226/// `transport` must be a live handle and `topic` a valid null-terminated UTF-8
227/// string.
228#[no_mangle]
229pub unsafe extern "C" fn pamoja_loopback_transport_subscribe(
230    transport: *mut PamojaLoopbackTransport,
231    topic: *const c_char,
232) -> PamojaStatus {
233    let Some(transport) = transport_handle(transport) else {
234        return PamojaStatus::InvalidArgument;
235    };
236    let Some(topic) = read_str(topic, "topic") else {
237        return PamojaStatus::InvalidArgument;
238    };
239    let inner = Arc::clone(&transport.inner);
240    let topic = topic.to_owned();
241    status(runtime().block_on(async move { inner.lock().await.subscribe(&topic).await }))
242}
243
244/// Waits for the next message on a subscribed topic.
245///
246/// # Arguments
247///
248/// * `transport` - the link.
249/// * `out_message` - receives a message handle, or null when the link is closed.
250///
251/// # Returns
252///
253/// [`PamojaStatus::Ok`] on success. A null `out_message` with an `Ok` status
254/// means the link closed rather than that anything failed.
255///
256/// # Safety
257///
258/// `transport` must be a live handle and `out_message` must be writable.
259#[no_mangle]
260pub unsafe extern "C" fn pamoja_loopback_transport_recv(
261    transport: *mut PamojaLoopbackTransport,
262    out_message: *mut *mut PamojaMessage,
263) -> PamojaStatus {
264    let Some(transport) = transport_handle(transport) else {
265        return PamojaStatus::InvalidArgument;
266    };
267    if out_message.is_null() {
268        set_last_error("out_message must not be null".to_owned());
269        return PamojaStatus::InvalidArgument;
270    }
271    *out_message = ptr::null_mut();
272
273    let inner = Arc::clone(&transport.inner);
274    match runtime().block_on(async move { inner.lock().await.recv().await }) {
275        Ok(Some(message)) => {
276            *out_message = PamojaMessage::into_raw(message.topic, message.payload);
277            PamojaStatus::Ok
278        }
279        Ok(None) => PamojaStatus::Ok,
280        Err(error) => {
281            let code = PamojaStatus::from_error(&error);
282            set_last_error(error.to_string());
283            code
284        }
285    }
286}
287
288/// Waits a limited time for the next message on a subscribed topic.
289///
290/// Running out of time loses nothing: a message that arrives afterwards waits for
291/// the next receive.
292///
293/// # Arguments
294///
295/// * `transport` - the link.
296/// * `timeout_ms` - how long to wait, in milliseconds.
297/// * `out_message` - receives a message handle, or null when the time ran out.
298/// * `out_timed_out` - receives whether the time ran out before a message arrived.
299///
300/// # Returns
301///
302/// [`PamojaStatus::Ok`] with a message or with the time run out, or
303/// [`PamojaStatus::Closed`] if the link is not connected.
304///
305/// # Safety
306///
307/// `transport` must be a live handle, and `out_message` and `out_timed_out` must
308/// be writable.
309#[no_mangle]
310pub unsafe extern "C" fn pamoja_loopback_transport_recv_within(
311    transport: *mut PamojaLoopbackTransport,
312    timeout_ms: u64,
313    out_message: *mut *mut PamojaMessage,
314    out_timed_out: *mut bool,
315) -> PamojaStatus {
316    let Some(transport) = transport_handle(transport) else {
317        return PamojaStatus::InvalidArgument;
318    };
319    let inner = Arc::clone(&transport.inner);
320    receive_within(timeout_ms, out_message, out_timed_out, || async move {
321        inner.lock().await.recv().await
322    })
323}
324
325/// Reports whether a link is connected.
326///
327/// # Arguments
328///
329/// * `transport` - the link.
330///
331/// # Returns
332///
333/// `true` when connected, or `false` if `transport` is null.
334///
335/// # Safety
336///
337/// `transport` must be a live handle from [`pamoja_loopback_transport_new`], or
338/// null.
339#[no_mangle]
340pub unsafe extern "C" fn pamoja_loopback_transport_is_connected(
341    transport: *mut PamojaLoopbackTransport,
342) -> bool {
343    let Some(transport) = transport_handle(transport) else {
344        return false;
345    };
346    let inner = Arc::clone(&transport.inner);
347    runtime().block_on(async move { inner.lock().await.is_connected() })
348}
349
350/// Marks a link disconnected, so sends over it fail.
351///
352/// # Arguments
353///
354/// * `transport` - the link.
355///
356/// # Safety
357///
358/// `transport` must be a live handle from [`pamoja_loopback_transport_new`], or
359/// null.
360#[no_mangle]
361pub unsafe extern "C" fn pamoja_loopback_transport_disconnect(
362    transport: *mut PamojaLoopbackTransport,
363) {
364    let Some(transport) = transport_handle(transport) else {
365        return;
366    };
367    let inner = Arc::clone(&transport.inner);
368    runtime().block_on(async move { inner.lock().await.disconnect() });
369}
370
371/// Releases a link handle.
372///
373/// Passing null is a no-op.
374///
375/// # Safety
376///
377/// `transport` must be a handle from [`pamoja_loopback_transport_new`] that has
378/// not already been freed, or null. After this call it must not be used again.
379#[no_mangle]
380pub unsafe extern "C" fn pamoja_loopback_transport_free(transport: *mut PamojaLoopbackTransport) {
381    if !transport.is_null() {
382        drop(Box::from_raw(transport));
383    }
384}
385
386/// Creates a loopback transport for composing into a ladder or a wrapper.
387///
388/// # Arguments
389///
390/// * `broker` - the broker to join, shared rather than consumed.
391///
392/// # Returns
393///
394/// A handle the caller must release with
395/// [`pamoja_transport_free`](crate::transport::pamoja_transport_free) or hand to
396/// a call that consumes it, or null if `broker` is null.
397///
398/// # Safety
399///
400/// `broker` must be a live handle from [`pamoja_loopback_broker_new`], or null.
401#[no_mangle]
402pub unsafe extern "C" fn pamoja_transport_loopback(
403    broker: *const PamojaLoopbackBroker,
404) -> *mut PamojaTransport {
405    let Some(broker) = broker_handle(broker) else {
406        return ptr::null_mut();
407    };
408    PamojaTransport::into_raw(Kind::Loopback(LoopbackTransport::new(broker.inner.clone())))
409}
410
411/// Borrows a broker handle, rejecting a null pointer.
412///
413/// # Safety
414///
415/// `broker` must be a live handle from [`pamoja_loopback_broker_new`], or null.
416unsafe fn broker_handle<'a>(
417    broker: *const PamojaLoopbackBroker,
418) -> Option<&'a PamojaLoopbackBroker> {
419    if broker.is_null() {
420        set_last_error("broker must not be null".to_owned());
421        return None;
422    }
423    Some(&*broker)
424}
425
426/// Borrows a link handle, rejecting a null pointer.
427///
428/// # Safety
429///
430/// `transport` must be a live handle from [`pamoja_loopback_transport_new`], or
431/// null.
432unsafe fn transport_handle<'a>(
433    transport: *mut PamojaLoopbackTransport,
434) -> Option<&'a PamojaLoopbackTransport> {
435    if transport.is_null() {
436        set_last_error("transport must not be null".to_owned());
437        return None;
438    }
439    Some(&*transport)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::transport::{
446        pamoja_message_free, pamoja_message_payload, pamoja_message_payload_len,
447    };
448
449    /// Reads a message handle out and releases it.
450    unsafe fn take(message: *mut PamojaMessage) -> Vec<u8> {
451        assert!(!message.is_null());
452        let bytes = std::slice::from_raw_parts(
453            pamoja_message_payload(message),
454            pamoja_message_payload_len(message),
455        )
456        .to_vec();
457        pamoja_message_free(message);
458        bytes
459    }
460
461    #[test]
462    fn a_published_message_reaches_a_subscriber() {
463        unsafe {
464            let broker = pamoja_loopback_broker_new();
465            let publisher = pamoja_loopback_transport_new(broker);
466            let subscriber = pamoja_loopback_transport_new(broker);
467
468            assert_eq!(
469                pamoja_loopback_transport_connect(publisher),
470                PamojaStatus::Ok
471            );
472            assert_eq!(
473                pamoja_loopback_transport_connect(subscriber),
474                PamojaStatus::Ok
475            );
476            assert!(pamoja_loopback_transport_is_connected(publisher));
477
478            let topic = std::ffi::CString::new("sensors/1").expect("static");
479            assert_eq!(
480                pamoja_loopback_transport_subscribe(subscriber, topic.as_ptr()),
481                PamojaStatus::Ok
482            );
483            assert_eq!(
484                pamoja_loopback_transport_send(publisher, topic.as_ptr(), b"21.5".as_ptr(), 4),
485                PamojaStatus::Ok
486            );
487
488            let mut message = ptr::null_mut();
489            assert_eq!(
490                pamoja_loopback_transport_recv(subscriber, &mut message),
491                PamojaStatus::Ok
492            );
493            assert_eq!(take(message), b"21.5");
494
495            pamoja_loopback_transport_free(subscriber);
496            pamoja_loopback_transport_free(publisher);
497            pamoja_loopback_broker_free(broker);
498        }
499    }
500
501    #[test]
502    fn a_disconnected_link_refuses_to_send() {
503        unsafe {
504            let broker = pamoja_loopback_broker_new();
505            let transport = pamoja_loopback_transport_new(broker);
506            pamoja_loopback_transport_connect(transport);
507            pamoja_loopback_transport_disconnect(transport);
508            assert!(!pamoja_loopback_transport_is_connected(transport));
509
510            let topic = std::ffi::CString::new("sensors/1").expect("static");
511            assert_ne!(
512                pamoja_loopback_transport_send(transport, topic.as_ptr(), b"x".as_ptr(), 1),
513                PamojaStatus::Ok
514            );
515
516            pamoja_loopback_transport_free(transport);
517            pamoja_loopback_broker_free(broker);
518        }
519    }
520
521    #[test]
522    fn a_timed_receive_runs_out_and_leaves_the_next_message_queued() {
523        use crate::transport::{
524            pamoja_transport_connect, pamoja_transport_free, pamoja_transport_recv_within,
525            pamoja_transport_subscribe,
526        };
527
528        unsafe {
529            let broker = pamoja_loopback_broker_new();
530            let topic = std::ffi::CString::new("sensors/1").expect("static");
531            let publisher = pamoja_loopback_transport_new(broker);
532            let link = pamoja_loopback_transport_new(broker);
533            let composed = pamoja_transport_loopback(broker);
534            pamoja_loopback_transport_connect(publisher);
535            pamoja_loopback_transport_connect(link);
536            pamoja_loopback_transport_subscribe(link, topic.as_ptr());
537            pamoja_transport_connect(composed);
538            pamoja_transport_subscribe(composed, topic.as_ptr());
539
540            let mut message = ptr::null_mut();
541            let mut timed_out = false;
542            assert_eq!(
543                pamoja_loopback_transport_recv_within(link, 20, &mut message, &mut timed_out),
544                PamojaStatus::Ok
545            );
546            assert!(timed_out && message.is_null());
547            assert_eq!(
548                pamoja_transport_recv_within(composed, 20, &mut message, &mut timed_out),
549                PamojaStatus::Ok
550            );
551            assert!(timed_out && message.is_null());
552
553            pamoja_loopback_transport_send(publisher, topic.as_ptr(), b"21.5".as_ptr(), 4);
554            assert_eq!(
555                pamoja_loopback_transport_recv_within(link, 1000, &mut message, &mut timed_out),
556                PamojaStatus::Ok
557            );
558            assert!(!timed_out);
559            assert_eq!(take(message), b"21.5");
560            assert_eq!(
561                pamoja_transport_recv_within(composed, 1000, &mut message, &mut timed_out),
562                PamojaStatus::Ok
563            );
564            assert!(!timed_out);
565            assert_eq!(take(message), b"21.5");
566
567            pamoja_loopback_transport_disconnect(link);
568            assert_eq!(
569                pamoja_loopback_transport_recv_within(link, 20, &mut message, &mut timed_out),
570                PamojaStatus::Closed
571            );
572            assert_eq!(
573                pamoja_loopback_transport_recv_within(link, 20, &mut message, ptr::null_mut()),
574                PamojaStatus::InvalidArgument
575            );
576
577            pamoja_transport_free(composed);
578            pamoja_loopback_transport_free(link);
579            pamoja_loopback_transport_free(publisher);
580            pamoja_loopback_broker_free(broker);
581        }
582    }
583
584    #[test]
585    fn a_null_handle_is_refused_rather_than_dereferenced() {
586        unsafe {
587            assert!(pamoja_loopback_transport_new(ptr::null()).is_null());
588            assert!(!pamoja_loopback_transport_is_connected(ptr::null_mut()));
589            assert_eq!(
590                pamoja_loopback_transport_connect(ptr::null_mut()),
591                PamojaStatus::InvalidArgument
592            );
593            pamoja_loopback_transport_free(ptr::null_mut());
594            pamoja_loopback_broker_free(ptr::null_mut());
595        }
596    }
597}