Skip to main content

pamoja_ffi/
host.rs

1//! A transport the host implements: callbacks that cross the C boundary.
2//!
3//! Everything else in this crate drives a transport pamoja wrote. This module
4//! goes the other way: the host supplies the functions that connect, send,
5//! subscribe, and receive, and pamoja wraps them in a transport that composes
6//! like any other, as a ladder rung, under a fault injector, or driven directly.
7//! It is how a link pamoja does not ship, a vendor SDK, a proprietary radio, a
8//! cloud client, or a class in a language binding, takes its place on a node.
9//!
10//! # The contract
11//!
12//! A host fills a [`PamojaTransportCallbacks`] and hands it, with a `user_data`
13//! pointer, to [`pamoja_transport_from_callbacks`]. Every callback receives that
14//! pointer back. The callbacks are ordinary blocking functions:
15//!
16//! - `connect`, `send`, and `subscribe` are called one at a time per transport,
17//!   from a thread pamoja owns, never the caller's thread. Each returns
18//!   [`PamojaStatus::Ok`] or a status describing the failure; a host that wants
19//!   the failure explained sets the text with [`pamoja_last_error_set`] on the
20//!   same thread before returning.
21//! - `recv` is optional. A link that only sends, an uplink, leaves it null and is
22//!   never listened on. When present it is called repeatedly from one background
23//!   thread, from the moment `connect` succeeds, each call as soon as the previous
24//!   returned. It blocks until a message is available, builds the message with
25//!   [`pamoja_message_new`], stores it in `out_message`, and returns
26//!   [`PamojaStatus::Ok`]; it stores null and returns [`PamojaStatus::Ok`] once
27//!   the link has ended and no further messages will arrive, which stops the
28//!   calls. A failure status stops them too, and the next receive through
29//!   pamoja reports that failure, with the text set by
30//!   [`pamoja_last_error_set`]. The next `connect` starts the calls again.
31//!   Because it runs on its own thread, it may overlap with the other three
32//!   callbacks.
33//! - `release` is optional. It is called once, after the transport is dropped and
34//!   after every callback still running has returned, so it may free whatever
35//!   `user_data` points at.
36//!
37//! Messages the host delivers are queued inside the transport, so a receive
38//! through pamoja is cancel-safe whatever the host's `recv` does.
39
40use std::ffi::{c_char, c_void, CString};
41use std::ptr;
42use std::sync::Arc;
43
44use pamoja_core::{Error, Message, Receive, Result, Transport};
45use tokio::sync::mpsc;
46use tokio::task::JoinHandle;
47
48use crate::transport::{Kind, PamojaMessage, PamojaTransport};
49use crate::{read_bytes, read_str, set_last_error, take_last_error, PamojaStatus};
50
51/// The functions a host supplies to stand as a transport.
52///
53/// `connect`, `send`, and `subscribe` are required. `recv` is null for a link that
54/// only sends. `release` is null when `user_data` needs no cleanup. The threading
55/// rules are in the module documentation.
56#[repr(C)]
57#[derive(Clone, Copy)]
58pub struct PamojaTransportCallbacks {
59    /// Establishes the link. Called once per `connect` on the transport.
60    pub connect: Option<unsafe extern "C" fn(user_data: *mut c_void) -> PamojaStatus>,
61    /// Publishes `payload_len` bytes at `payload` to the null-terminated UTF-8
62    /// `topic`. Both pointers are valid only for the duration of the call.
63    pub send: Option<
64        unsafe extern "C" fn(
65            user_data: *mut c_void,
66            topic: *const c_char,
67            payload: *const u8,
68            payload_len: usize,
69        ) -> PamojaStatus,
70    >,
71    /// Subscribes to the null-terminated UTF-8 `topic` filter, valid only for the
72    /// duration of the call.
73    pub subscribe:
74        Option<unsafe extern "C" fn(user_data: *mut c_void, topic: *const c_char) -> PamojaStatus>,
75    /// Waits for the next message on a subscribed topic and stores it in
76    /// `out_message`, or stores null once the link has ended. Null for a link that
77    /// only sends.
78    pub recv: Option<
79        unsafe extern "C" fn(
80            user_data: *mut c_void,
81            out_message: *mut *mut PamojaMessage,
82        ) -> PamojaStatus,
83    >,
84    /// Frees whatever `user_data` refers to, once nothing will call the host again.
85    pub release: Option<unsafe extern "C" fn(user_data: *mut c_void)>,
86}
87
88/// The host's side of a transport: its callbacks and the pointer they receive.
89///
90/// Shared between the transport and every callback in flight, so `release` runs
91/// only after the last of them has returned.
92struct Host {
93    callbacks: PamojaTransportCallbacks,
94    user_data: *mut c_void,
95}
96
97// SAFETY: the callbacks are documented as callable from any thread, with the
98// serialization the module documentation states, and the pointer is only ever
99// handed back to them. Nothing here reads through it.
100unsafe impl Send for Host {}
101unsafe impl Sync for Host {}
102
103impl Drop for Host {
104    fn drop(&mut self) {
105        if let Some(release) = self.callbacks.release {
106            // SAFETY: the host promised `release` accepts the pointer it gave
107            // `pamoja_transport_from_callbacks`, and this runs once, after every
108            // callback holding the host has returned.
109            unsafe { release(self.user_data) }
110        }
111    }
112}
113
114/// Turns the status a callback returned, and any text it set on this thread, into
115/// a core result. Runs on the thread that made the call so the text is the one the
116/// host set.
117fn outcome(status: PamojaStatus) -> Result<()> {
118    if status == PamojaStatus::Ok {
119        return Ok(());
120    }
121    let reason =
122        take_last_error().unwrap_or_else(|| format!("the host transport reported {status:?}"));
123    Err(match status {
124        PamojaStatus::Io => Error::Io(reason),
125        PamojaStatus::Codec => Error::Codec(reason),
126        PamojaStatus::Closed => Error::Closed,
127        PamojaStatus::Auth => Error::Auth(reason),
128        _ => Error::Transport(reason),
129    })
130}
131
132/// Runs a host callback on the runtime's blocking pool, so a host that blocks does
133/// not stall the tasks driving other transports.
134async fn blocking<T: Send + 'static>(call: impl FnOnce() -> T + Send + 'static) -> Result<T> {
135    tokio::task::spawn_blocking(call)
136        .await
137        .map_err(|error| Error::Transport(format!("the host callback thread failed: {error}")))
138}
139
140/// Converts a topic to the C string a callback receives.
141fn c_topic(topic: &str) -> Result<CString> {
142    CString::new(topic)
143        .map_err(|_| Error::Transport("a topic cannot carry an interior null byte".to_owned()))
144}
145
146/// A transport whose every operation is a host callback.
147///
148/// Built by [`pamoja_transport_from_callbacks`] and held inside a
149/// [`PamojaTransport`], so it composes wherever a transport does.
150pub struct CallbackTransport {
151    host: Arc<Host>,
152    connected: bool,
153    inbox: Option<mpsc::UnboundedReceiver<Result<Message>>>,
154    pump: Option<JoinHandle<()>>,
155}
156
157impl CallbackTransport {
158    /// Whether the host supplied a `recv`, so the transport delivers and a ladder
159    /// listens on it.
160    pub(crate) fn listens(&self) -> bool {
161        self.host.callbacks.recv.is_some()
162    }
163
164    /// Starts the thread that asks the host for messages until the link ends or
165    /// `recv` fails, queueing a failure as the last thing it delivers.
166    fn start_pump(&mut self) {
167        let Some(recv) = self.host.callbacks.recv else {
168            return;
169        };
170        if let Some(pump) = self.pump.take() {
171            pump.abort();
172        }
173        let (sender, receiver) = mpsc::unbounded_channel();
174        let host = Arc::clone(&self.host);
175        self.inbox = Some(receiver);
176        self.pump = Some(tokio::spawn(async move {
177            loop {
178                let host = Arc::clone(&host);
179                let next = blocking(move || {
180                    let mut out_message: *mut PamojaMessage = ptr::null_mut();
181                    // SAFETY: the host promised `recv` accepts its pointer and a
182                    // writable slot, and `out_message` is either null or a message
183                    // built with `pamoja_message_new`, which this side now owns.
184                    let status = unsafe { recv(host.user_data, &mut out_message) };
185                    let delivered = (!out_message.is_null())
186                        .then(|| unsafe { Box::from_raw(out_message) }.into_message());
187                    outcome(status).map(|()| delivered)
188                })
189                .await;
190                let delivered = match next {
191                    Ok(Ok(Some(message))) => Ok(message),
192                    Ok(Ok(None)) => break,
193                    Ok(Err(error)) | Err(error) => Err(error),
194                };
195                let failed = delivered.is_err();
196                if sender.send(delivered).is_err() || failed {
197                    break;
198                }
199            }
200        }));
201    }
202}
203
204impl Drop for CallbackTransport {
205    fn drop(&mut self) {
206        if let Some(pump) = self.pump.take() {
207            pump.abort();
208        }
209    }
210}
211
212impl Transport for CallbackTransport {
213    async fn connect(&mut self) -> Result<()> {
214        let host = Arc::clone(&self.host);
215        blocking(move || {
216            let connect = host.callbacks.connect.expect("required at creation");
217            // SAFETY: the host promised `connect` accepts its pointer.
218            outcome(unsafe { connect(host.user_data) })
219        })
220        .await??;
221        self.connected = true;
222        self.start_pump();
223        Ok(())
224    }
225
226    async fn send(&mut self, topic: &str, payload: &[u8]) -> Result<()> {
227        if !self.connected {
228            return Err(Error::Closed);
229        }
230        let host = Arc::clone(&self.host);
231        let topic = c_topic(topic)?;
232        let payload = payload.to_vec();
233        blocking(move || {
234            let send = host.callbacks.send.expect("required at creation");
235            // SAFETY: the host promised `send` accepts its pointer, a C string, and
236            // a byte range, all of which outlive the call.
237            outcome(unsafe {
238                send(
239                    host.user_data,
240                    topic.as_ptr(),
241                    payload.as_ptr(),
242                    payload.len(),
243                )
244            })
245        })
246        .await?
247    }
248
249    async fn subscribe(&mut self, topic: &str) -> Result<()> {
250        if !self.connected {
251            return Err(Error::Closed);
252        }
253        let host = Arc::clone(&self.host);
254        let topic = c_topic(topic)?;
255        blocking(move || {
256            let subscribe = host.callbacks.subscribe.expect("required at creation");
257            // SAFETY: the host promised `subscribe` accepts its pointer and a C
258            // string that outlives the call.
259            outcome(unsafe { subscribe(host.user_data, topic.as_ptr()) })
260        })
261        .await?
262    }
263}
264
265impl Receive for CallbackTransport {
266    /// Awaits the next message the host delivered.
267    ///
268    /// # Returns
269    ///
270    /// `Some(message)` for the next queued message; `None` once the host reported
271    /// the link ended or after a failure was reported, or at once for a host with
272    /// no `recv`.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`Error::Closed`] if the transport is not connected, and once the
277    /// failure the host's `recv` reported, after which it is called no more until
278    /// the next connect.
279    async fn recv(&mut self) -> Result<Option<Message>> {
280        if !self.connected {
281            return Err(Error::Closed);
282        }
283        match self.inbox.as_mut() {
284            Some(inbox) => inbox.recv().await.transpose(),
285            None => Ok(None),
286        }
287    }
288}
289
290/// Wraps host callbacks in a transport.
291///
292/// # Arguments
293///
294/// * `callbacks` - the host's functions, copied by this call; the struct need not
295///   outlive it.
296/// * `user_data` - the pointer every callback receives, owned by the host until
297///   `release` is called with it.
298///
299/// # Returns
300///
301/// A handle the caller releases with [`pamoja_transport_free`] or hands to a call
302/// that consumes it, such as adding it to a ladder. Null if `callbacks` is null or
303/// lacks `connect`, `send`, or `subscribe`; the host then keeps `user_data` and
304/// `release` is never called.
305///
306/// # Safety
307///
308/// `callbacks` must point to a valid struct for the duration of the call, and
309/// each function in it must honor the contract in the module documentation for as
310/// long as the transport, or any transport composed from it, lives.
311///
312/// [`pamoja_transport_free`]: crate::transport::pamoja_transport_free
313#[no_mangle]
314pub unsafe extern "C" fn pamoja_transport_from_callbacks(
315    callbacks: *const PamojaTransportCallbacks,
316    user_data: *mut c_void,
317) -> *mut PamojaTransport {
318    if callbacks.is_null() {
319        set_last_error("callbacks must not be null".to_owned());
320        return ptr::null_mut();
321    }
322    let callbacks = *callbacks;
323    if callbacks.connect.is_none() || callbacks.send.is_none() || callbacks.subscribe.is_none() {
324        set_last_error("a host transport needs connect, send, and subscribe callbacks".to_owned());
325        return ptr::null_mut();
326    }
327    let transport = CallbackTransport {
328        host: Arc::new(Host {
329            callbacks,
330            user_data,
331        }),
332        connected: false,
333        inbox: None,
334        pump: None,
335    };
336    PamojaTransport::into_raw(Kind::Host(transport))
337}
338
339/// Builds a message for a host's `recv` callback to deliver.
340///
341/// # Arguments
342///
343/// * `topic` - the topic the message arrived on, as null-terminated UTF-8.
344/// * `payload` - the payload bytes, or null when `payload_len` is 0.
345/// * `payload_len` - the length of `payload`.
346///
347/// # Returns
348///
349/// A message the receiving side owns once it is stored in `out_message`; release
350/// it with [`pamoja_message_free`] only if it is not handed over. Null if `topic`
351/// is null or not UTF-8, or `payload` is null with a nonzero length.
352///
353/// # Safety
354///
355/// `topic` must be a valid null-terminated string and `payload` must point to at
356/// least `payload_len` readable bytes or be null when that length is 0.
357///
358/// [`pamoja_message_free`]: crate::transport::pamoja_message_free
359#[no_mangle]
360pub unsafe extern "C" fn pamoja_message_new(
361    topic: *const c_char,
362    payload: *const u8,
363    payload_len: usize,
364) -> *mut PamojaMessage {
365    let Some(topic) = read_str(topic, "topic") else {
366        return ptr::null_mut();
367    };
368    let Ok(payload) = read_bytes(payload, payload_len) else {
369        return ptr::null_mut();
370    };
371    PamojaMessage::into_raw(topic.to_owned(), payload)
372}
373
374/// Records the text a host callback wants attached to the status it returns.
375///
376/// Call it on the thread the callback is running on, before returning the
377/// status; pamoja reads it there and carries it to whoever made the call, where
378/// [`pamoja_last_error_message`] then reports it.
379///
380/// # Arguments
381///
382/// * `message` - the description as null-terminated UTF-8, or null to clear it.
383///
384/// # Safety
385///
386/// `message` must be a valid null-terminated string, or null.
387///
388/// [`pamoja_last_error_message`]: crate::pamoja_last_error_message
389#[no_mangle]
390pub unsafe extern "C" fn pamoja_last_error_set(message: *const c_char) {
391    match read_str(message, "message") {
392        Some(message) => set_last_error(message.to_owned()),
393        None => drop(take_last_error()),
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    use std::collections::VecDeque;
402    use std::ffi::CStr;
403    use std::sync::atomic::{AtomicBool, Ordering};
404    use std::sync::{Condvar, Mutex};
405
406    use crate::transport::{
407        pamoja_message_free, pamoja_message_payload, pamoja_message_payload_len,
408        pamoja_message_topic, pamoja_transport_connect, pamoja_transport_free,
409        pamoja_transport_recv, pamoja_transport_send, pamoja_transport_subscribe,
410    };
411    use crate::{pamoja_last_error_message, PamojaStatus};
412
413    /// A host written in Rust: records what it is asked to do and delivers what the
414    /// test feeds it.
415    #[derive(Default)]
416    struct FakeHost {
417        connects: Mutex<usize>,
418        sent: Mutex<Vec<(String, Vec<u8>)>>,
419        filters: Mutex<Vec<String>>,
420        inbox: Mutex<VecDeque<std::result::Result<Option<Message>, &'static str>>>,
421        ready: Condvar,
422        refuse_sends: AtomicBool,
423        released: Arc<AtomicBool>,
424    }
425
426    impl FakeHost {
427        fn deliver(&self, next: Option<Message>) {
428            self.inbox.lock().expect("inbox").push_back(Ok(next));
429            self.ready.notify_all();
430        }
431
432        fn fail(&self, reason: &'static str) {
433            self.inbox.lock().expect("inbox").push_back(Err(reason));
434            self.ready.notify_all();
435        }
436    }
437
438    unsafe extern "C" fn connect(user_data: *mut c_void) -> PamojaStatus {
439        let host = &*(user_data as *const FakeHost);
440        *host.connects.lock().expect("connects") += 1;
441        PamojaStatus::Ok
442    }
443
444    unsafe extern "C" fn send(
445        user_data: *mut c_void,
446        topic: *const c_char,
447        payload: *const u8,
448        payload_len: usize,
449    ) -> PamojaStatus {
450        let host = &*(user_data as *const FakeHost);
451        if host.refuse_sends.load(Ordering::SeqCst) {
452            let reason = CString::new("the radio is out of range").expect("static");
453            pamoja_last_error_set(reason.as_ptr());
454            return PamojaStatus::Transport;
455        }
456        let topic = CStr::from_ptr(topic).to_str().expect("utf-8").to_owned();
457        let payload = std::slice::from_raw_parts(payload, payload_len).to_vec();
458        host.sent.lock().expect("sent").push((topic, payload));
459        PamojaStatus::Ok
460    }
461
462    unsafe extern "C" fn subscribe(user_data: *mut c_void, topic: *const c_char) -> PamojaStatus {
463        let host = &*(user_data as *const FakeHost);
464        let topic = CStr::from_ptr(topic).to_str().expect("utf-8").to_owned();
465        host.filters.lock().expect("filters").push(topic);
466        PamojaStatus::Ok
467    }
468
469    unsafe extern "C" fn recv(
470        user_data: *mut c_void,
471        out_message: *mut *mut PamojaMessage,
472    ) -> PamojaStatus {
473        let host = &*(user_data as *const FakeHost);
474        let mut inbox = host.inbox.lock().expect("inbox");
475        while inbox.is_empty() {
476            inbox = host.ready.wait(inbox).expect("inbox");
477        }
478        *out_message = match inbox.pop_front().expect("non-empty") {
479            Ok(Some(message)) => {
480                let topic = CString::new(message.topic).expect("topic");
481                pamoja_message_new(
482                    topic.as_ptr(),
483                    message.payload.as_ptr(),
484                    message.payload.len(),
485                )
486            }
487            Ok(None) => ptr::null_mut(),
488            Err(reason) => {
489                let reason = CString::new(reason).expect("static");
490                pamoja_last_error_set(reason.as_ptr());
491                return PamojaStatus::Transport;
492            }
493        };
494        PamojaStatus::Ok
495    }
496
497    unsafe extern "C" fn release(user_data: *mut c_void) {
498        let host = Box::from_raw(user_data as *mut FakeHost);
499        host.released.store(true, Ordering::SeqCst);
500    }
501
502    /// Leaks a fake host to the C side and returns the callbacks that reach it.
503    fn fake_host(listens: bool) -> (*mut FakeHost, PamojaTransportCallbacks, Arc<AtomicBool>) {
504        let host = Box::new(FakeHost::default());
505        let released = Arc::clone(&host.released);
506        let host = Box::into_raw(host);
507        let callbacks = PamojaTransportCallbacks {
508            connect: Some(connect),
509            send: Some(send),
510            subscribe: Some(subscribe),
511            recv: listens.then_some(recv as unsafe extern "C" fn(_, _) -> _),
512            release: Some(release),
513        };
514        (host, callbacks, released)
515    }
516
517    fn last_error() -> String {
518        unsafe {
519            CStr::from_ptr(pamoja_last_error_message())
520                .to_str()
521                .expect("utf-8")
522                .to_owned()
523        }
524    }
525
526    #[test]
527    fn the_host_carries_sends_and_delivers_what_it_receives() {
528        unsafe {
529            let (host, callbacks, released) = fake_host(true);
530            let transport = pamoja_transport_from_callbacks(&callbacks, host.cast());
531            assert!(!transport.is_null());
532            let topic = CString::new("sensors/1").expect("static");
533
534            assert_eq!(pamoja_transport_connect(transport), PamojaStatus::Ok);
535            assert_eq!(
536                pamoja_transport_subscribe(transport, topic.as_ptr()),
537                PamojaStatus::Ok
538            );
539            assert_eq!(
540                pamoja_transport_send(transport, topic.as_ptr(), b"21.5".as_ptr(), 4),
541                PamojaStatus::Ok
542            );
543            assert_eq!(*(*host).connects.lock().expect("connects"), 1);
544            assert_eq!(
545                (*host).filters.lock().expect("filters").as_slice(),
546                ["sensors/1"]
547            );
548            assert_eq!(
549                (*host).sent.lock().expect("sent").as_slice(),
550                [("sensors/1".to_owned(), b"21.5".to_vec())]
551            );
552
553            // What the host delivers comes out of the transport, in order.
554            (*host).deliver(Some(Message::new("commands/1", b"open")));
555            (*host).deliver(Some(Message::new("commands/1", b"close")));
556            for expected in [b"open".as_slice(), b"close".as_slice()] {
557                let mut message = ptr::null_mut();
558                assert_eq!(
559                    pamoja_transport_recv(transport, &mut message),
560                    PamojaStatus::Ok
561                );
562                assert!(!message.is_null());
563                assert_eq!(
564                    CStr::from_ptr(pamoja_message_topic(message)).to_bytes(),
565                    b"commands/1"
566                );
567                let payload = std::slice::from_raw_parts(
568                    pamoja_message_payload(message),
569                    pamoja_message_payload_len(message),
570                );
571                assert_eq!(payload, expected);
572                pamoja_message_free(message);
573            }
574
575            // The host ends the link: the receive reports it, and once the transport
576            // goes the host is released, after its last recv returned.
577            (*host).deliver(None);
578            let mut message = ptr::null_mut();
579            assert_eq!(
580                pamoja_transport_recv(transport, &mut message),
581                PamojaStatus::Ok
582            );
583            assert!(message.is_null());
584            assert!(!released.load(Ordering::SeqCst));
585            pamoja_transport_free(transport);
586            for _ in 0..200 {
587                if released.load(Ordering::SeqCst) {
588                    break;
589                }
590                std::thread::sleep(std::time::Duration::from_millis(5));
591            }
592            assert!(
593                released.load(Ordering::SeqCst),
594                "release runs after the pump ends"
595            );
596        }
597    }
598
599    #[test]
600    fn a_host_that_refuses_a_send_explains_itself() {
601        unsafe {
602            let (host, callbacks, _released) = fake_host(false);
603            let transport = pamoja_transport_from_callbacks(&callbacks, host.cast());
604            let topic = CString::new("t").expect("static");
605            assert_eq!(pamoja_transport_connect(transport), PamojaStatus::Ok);
606
607            (*host).refuse_sends.store(true, Ordering::SeqCst);
608            assert_eq!(
609                pamoja_transport_send(transport, topic.as_ptr(), b"x".as_ptr(), 1),
610                PamojaStatus::Transport
611            );
612            assert_eq!(last_error(), "transport error: the radio is out of range");
613
614            // A send-only host reports an ended link rather than waiting.
615            let mut message = ptr::null_mut();
616            assert_eq!(
617                pamoja_transport_recv(transport, &mut message),
618                PamojaStatus::Ok
619            );
620            assert!(message.is_null());
621            pamoja_transport_free(transport);
622        }
623    }
624
625    #[test]
626    fn a_failed_recv_is_reported_once_and_the_next_connect_listens_again() {
627        unsafe {
628            let (host, callbacks, _released) = fake_host(true);
629            let transport = pamoja_transport_from_callbacks(&callbacks, host.cast());
630            assert_eq!(pamoja_transport_connect(transport), PamojaStatus::Ok);
631
632            (*host).fail("the modem lost its session");
633            let mut message = ptr::null_mut();
634            assert_eq!(
635                pamoja_transport_recv(transport, &mut message),
636                PamojaStatus::Transport
637            );
638            assert_eq!(last_error(), "transport error: the modem lost its session");
639            assert_eq!(
640                pamoja_transport_recv(transport, &mut message),
641                PamojaStatus::Ok
642            );
643            assert!(message.is_null());
644
645            assert_eq!(pamoja_transport_connect(transport), PamojaStatus::Ok);
646            (*host).deliver(Some(Message::new("commands/interval", b"900")));
647            assert_eq!(
648                pamoja_transport_recv(transport, &mut message),
649                PamojaStatus::Ok
650            );
651            assert!(!message.is_null());
652            pamoja_message_free(message);
653
654            (*host).deliver(None);
655            pamoja_transport_free(transport);
656        }
657    }
658
659    #[test]
660    fn operations_before_connect_report_closed() {
661        unsafe {
662            let (host, callbacks, _released) = fake_host(true);
663            let transport = pamoja_transport_from_callbacks(&callbacks, host.cast());
664            let topic = CString::new("t").expect("static");
665            assert_eq!(
666                pamoja_transport_send(transport, topic.as_ptr(), b"x".as_ptr(), 1),
667                PamojaStatus::Closed
668            );
669            let mut message = ptr::null_mut();
670            assert_eq!(
671                pamoja_transport_recv(transport, &mut message),
672                PamojaStatus::Closed
673            );
674            pamoja_transport_free(transport);
675        }
676    }
677
678    #[test]
679    fn incomplete_callbacks_are_refused_and_the_host_keeps_its_data() {
680        unsafe {
681            let (host, mut callbacks, released) = fake_host(true);
682            callbacks.send = None;
683            assert!(pamoja_transport_from_callbacks(&callbacks, host.cast()).is_null());
684            assert_eq!(
685                last_error(),
686                "a host transport needs connect, send, and subscribe callbacks"
687            );
688            assert!(pamoja_transport_from_callbacks(ptr::null(), host.cast()).is_null());
689            assert!(!released.load(Ordering::SeqCst));
690            drop(Box::from_raw(host));
691        }
692    }
693
694    #[test]
695    fn a_message_needs_a_topic_and_a_readable_payload() {
696        unsafe {
697            let topic = CString::new("t").expect("static");
698            assert!(pamoja_message_new(ptr::null(), ptr::null(), 0).is_null());
699            assert!(pamoja_message_new(topic.as_ptr(), ptr::null(), 3).is_null());
700            let empty = pamoja_message_new(topic.as_ptr(), ptr::null(), 0);
701            assert!(!empty.is_null());
702            assert_eq!(pamoja_message_payload_len(empty), 0);
703            pamoja_message_free(empty);
704        }
705    }
706}