Skip to main content

konjure_ffi/
lib.rs

1//! C-compatible, panic-contained access to the portable Konjure runtime.
2//!
3//! No Rust pointer is exposed as an ABI handle. A handle names a registry slot
4//! and generation, so use-after-dispose and stale handles are deterministic
5//! errors rather than undefined behavior.
6
7use std::{
8    panic::{AssertUnwindSafe, catch_unwind},
9    sync::{Mutex, OnceLock},
10};
11
12use konjure_sdk::{EntityId, Event, Geometry, Mesh, Runtime, SceneDocument, compile, validate};
13use serde::Serialize;
14
15pub const KJ_ABI_VERSION: u32 = 1;
16const MAX_JSON_BYTES: usize = 1024 * 1024;
17// Dispatch mutates runtime state. The first call therefore preflights this
18// bounded receipt capacity without executing the event; callers retry once with
19// this capacity and observe exactly one transition.
20const DISPATCH_RECEIPT_CAPACITY: usize = MAX_JSON_BYTES;
21
22#[repr(C)]
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct KjHandle {
25    pub slot: u32,
26    pub generation: u32,
27}
28
29#[repr(C)]
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum KjStatus {
32    Ok = 0,
33    InvalidArgument = 1,
34    InvalidHandle = 2,
35    BufferTooSmall = 3,
36    CompileError = 4,
37    RuntimeError = 5,
38    InternalError = 6,
39}
40
41#[derive(Serialize)]
42struct MeshBatch<'a> {
43    entries: Vec<MeshEntry<'a>>,
44}
45
46#[derive(Serialize)]
47struct MeshEntry<'a> {
48    entity: &'a EntityId,
49    topology: MeshTopology,
50    mesh: &'a Mesh,
51}
52
53#[derive(Serialize)]
54#[serde(rename_all = "snake_case")]
55enum MeshTopology {
56    Triangles,
57    Lines,
58}
59
60struct Entry {
61    generation: u32,
62    runtime: Option<Runtime>,
63}
64
65#[derive(Default)]
66struct Registry {
67    entries: Vec<Entry>,
68}
69
70impl Registry {
71    fn insert(&mut self, runtime: Runtime) -> KjHandle {
72        for (slot, entry) in self.entries.iter_mut().enumerate() {
73            if entry.runtime.is_none() {
74                entry.runtime = Some(runtime);
75                return KjHandle {
76                    slot: u32::try_from(slot).expect("registry slot fits u32"),
77                    generation: entry.generation,
78                };
79            }
80        }
81        let slot = u32::try_from(self.entries.len()).expect("registry slot fits u32");
82        self.entries.push(Entry {
83            generation: 1,
84            runtime: Some(runtime),
85        });
86        KjHandle {
87            slot,
88            generation: 1,
89        }
90    }
91
92    fn get(&self, handle: KjHandle) -> Option<&Runtime> {
93        self.entries
94            .get(usize::try_from(handle.slot).ok()?)
95            .filter(|entry| entry.generation == handle.generation)
96            .and_then(|entry| entry.runtime.as_ref())
97    }
98
99    fn get_mut(&mut self, handle: KjHandle) -> Option<&mut Runtime> {
100        self.entries
101            .get_mut(usize::try_from(handle.slot).ok()?)
102            .filter(|entry| entry.generation == handle.generation)
103            .and_then(|entry| entry.runtime.as_mut())
104    }
105
106    fn remove(&mut self, handle: KjHandle) -> bool {
107        let Some(entry) = self
108            .entries
109            .get_mut(usize::try_from(handle.slot).ok().unwrap_or(usize::MAX))
110        else {
111            return false;
112        };
113        if entry.generation != handle.generation || entry.runtime.take().is_none() {
114            return false;
115        }
116        entry.generation = entry.generation.wrapping_add(1).max(1);
117        true
118    }
119}
120
121fn registry() -> &'static Mutex<Registry> {
122    static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
123    REGISTRY.get_or_init(|| Mutex::new(Registry::default()))
124}
125
126fn write_bytes(bytes: &[u8], output: *mut u8, capacity: usize, required: *mut usize) -> KjStatus {
127    if required.is_null() {
128        return KjStatus::InvalidArgument;
129    }
130    // SAFETY: `required` was checked non-null and is caller-owned writable storage.
131    unsafe {
132        *required = bytes.len();
133    }
134    if bytes.is_empty() {
135        return KjStatus::Ok;
136    }
137    if output.is_null() || capacity < bytes.len() {
138        return KjStatus::BufferTooSmall;
139    }
140    // SAFETY: caller provides an output region with at least `bytes.len()` bytes.
141    unsafe {
142        std::ptr::copy_nonoverlapping(bytes.as_ptr(), output, bytes.len());
143    }
144    KjStatus::Ok
145}
146
147fn write_error(message: &str, output: *mut u8, capacity: usize, required: *mut usize) {
148    let _ = write_bytes(message.as_bytes(), output, capacity, required);
149}
150
151fn read_utf8<'a>(input: *const u8, length: usize, max: usize) -> Result<&'a str, KjStatus> {
152    if length > max || (length > 0 && input.is_null()) {
153        return Err(KjStatus::InvalidArgument);
154    }
155    // `from_raw_parts` requires a non-null pointer even for an empty slice.
156    // Empty C input is valid and lets the Rust parser produce its normal diagnostic.
157    if length == 0 {
158        return Ok("");
159    }
160    // SAFETY: the C caller contract requires a readable `length`-byte region when nonempty.
161    let bytes = unsafe { std::slice::from_raw_parts(input, length) };
162    std::str::from_utf8(bytes).map_err(|_| KjStatus::InvalidArgument)
163}
164
165fn contained(
166    error: *mut u8,
167    error_capacity: usize,
168    error_required: *mut usize,
169    operation: impl FnOnce() -> Result<KjStatus, (KjStatus, String)>,
170) -> KjStatus {
171    match catch_unwind(AssertUnwindSafe(operation)) {
172        Ok(Ok(status)) => {
173            write_error("", error, error_capacity, error_required);
174            status
175        }
176        Ok(Err((status, message))) => {
177            write_error(&message, error, error_capacity, error_required);
178            status
179        }
180        Err(_) => {
181            write_error(
182                "internal Konjure panic contained at FFI boundary",
183                error,
184                error_capacity,
185                error_required,
186            );
187            KjStatus::InternalError
188        }
189    }
190}
191
192fn serialize<T: serde::Serialize>(
193    value: &T,
194    output: *mut u8,
195    capacity: usize,
196    required: *mut usize,
197) -> Result<KjStatus, (KjStatus, String)> {
198    serde_json::to_vec(value)
199        .map(|bytes| write_bytes(&bytes, output, capacity, required))
200        .map_err(|error| (KjStatus::InternalError, error.to_string()))
201}
202
203#[derive(Clone, Copy)]
204struct OutputBuffer {
205    output: *mut u8,
206    capacity: usize,
207    required: *mut usize,
208}
209
210#[derive(Clone, Copy)]
211struct ErrorBuffer {
212    output: *mut u8,
213    capacity: usize,
214    required: *mut usize,
215}
216
217/// Returns the ABI major version implemented by this library.
218#[unsafe(no_mangle)]
219pub extern "C" fn kj_abi_version() -> u32 {
220    KJ_ABI_VERSION
221}
222
223/// Compiles DSL source and places a generation-safe runtime handle in `out_handle`.
224///
225/// # Safety
226/// Non-null pointer arguments must point to readable/writable regions described by
227/// their corresponding lengths. Error buffers are optional but `error_required` is required.
228#[unsafe(no_mangle)]
229pub unsafe extern "C" fn kj_runtime_compile(
230    source: *const u8,
231    source_len: usize,
232    out_handle: *mut KjHandle,
233    error: *mut u8,
234    error_capacity: usize,
235    error_required: *mut usize,
236) -> KjStatus {
237    contained(error, error_capacity, error_required, || {
238        if out_handle.is_null() {
239            return Err((KjStatus::InvalidArgument, "out_handle is required".into()));
240        }
241        let source = read_utf8(source, source_len, konjure_sdk::MAX_SOURCE_BYTES)
242            .map_err(|status| (status, "source must be valid bounded UTF-8".into()))?;
243        let scene = compile(source).map_err(|error| (KjStatus::CompileError, error.to_string()))?;
244        let runtime =
245            Runtime::new(scene).map_err(|error| (KjStatus::RuntimeError, error.to_string()))?;
246        let handle = registry()
247            .lock()
248            .map_err(|_| (KjStatus::InternalError, "runtime registry poisoned".into()))?
249            .insert(runtime);
250        // SAFETY: checked non-null and caller promises writable KjHandle storage.
251        unsafe {
252            *out_handle = handle;
253        }
254        Ok(KjStatus::Ok)
255    })
256}
257
258/// Creates a runtime from a serialized `SceneDocument` JSON value.
259///
260/// # Safety
261/// See [`kj_runtime_compile`].
262#[unsafe(no_mangle)]
263pub unsafe extern "C" fn kj_runtime_from_document_json(
264    document: *const u8,
265    document_len: usize,
266    out_handle: *mut KjHandle,
267    error: *mut u8,
268    error_capacity: usize,
269    error_required: *mut usize,
270) -> KjStatus {
271    contained(error, error_capacity, error_required, || {
272        if out_handle.is_null() {
273            return Err((KjStatus::InvalidArgument, "out_handle is required".into()));
274        }
275        let document = read_utf8(document, document_len, MAX_JSON_BYTES)
276            .map_err(|status| (status, "document must be valid bounded UTF-8".into()))?;
277        let scene: SceneDocument = serde_json::from_str(document)
278            .map_err(|error| (KjStatus::InvalidArgument, error.to_string()))?;
279        validate(&scene).map_err(|error| (KjStatus::RuntimeError, error.to_string()))?;
280        let handle = registry()
281            .lock()
282            .map_err(|_| (KjStatus::InternalError, "runtime registry poisoned".into()))?
283            .insert(Runtime::new(scene).expect("validated scene constructs runtime"));
284        // SAFETY: checked non-null and caller promises writable KjHandle storage.
285        unsafe {
286            *out_handle = handle;
287        }
288        Ok(KjStatus::Ok)
289    })
290}
291
292/// Disposes a runtime. A second disposal, or any stale copied handle, returns `InvalidHandle`.
293#[unsafe(no_mangle)]
294pub extern "C" fn kj_runtime_dispose(
295    handle: KjHandle,
296    error: *mut u8,
297    error_capacity: usize,
298    error_required: *mut usize,
299) -> KjStatus {
300    contained(error, error_capacity, error_required, || {
301        if registry()
302            .lock()
303            .map_err(|_| (KjStatus::InternalError, "runtime registry poisoned".into()))?
304            .remove(handle)
305        {
306            Ok(KjStatus::Ok)
307        } else {
308            Err((
309                KjStatus::InvalidHandle,
310                "invalid or disposed runtime handle".into(),
311            ))
312        }
313    })
314}
315
316fn query(
317    handle: KjHandle,
318    output: OutputBuffer,
319    error: ErrorBuffer,
320    operation: impl FnOnce(&Runtime) -> Result<Vec<u8>, String>,
321) -> KjStatus {
322    contained(error.output, error.capacity, error.required, || {
323        let registry = registry()
324            .lock()
325            .map_err(|_| (KjStatus::InternalError, "runtime registry poisoned".into()))?;
326        let runtime = registry.get(handle).ok_or((
327            KjStatus::InvalidHandle,
328            "invalid or disposed runtime handle".into(),
329        ))?;
330        let bytes = operation(runtime).map_err(|message| (KjStatus::RuntimeError, message))?;
331        Ok(write_bytes(
332            &bytes,
333            output.output,
334            output.capacity,
335            output.required,
336        ))
337    })
338}
339
340/// Serializes the authoritative scene document as UTF-8 JSON.
341#[unsafe(no_mangle)]
342pub extern "C" fn kj_runtime_scene_json(
343    handle: KjHandle,
344    output: *mut u8,
345    capacity: usize,
346    required: *mut usize,
347    error: *mut u8,
348    error_capacity: usize,
349    error_required: *mut usize,
350) -> KjStatus {
351    query(
352        handle,
353        OutputBuffer {
354            output,
355            capacity,
356            required,
357        },
358        ErrorBuffer {
359            output: error,
360            capacity: error_capacity,
361            required: error_required,
362        },
363        |runtime| serde_json::to_vec(runtime.scene()).map_err(|error| error.to_string()),
364    )
365}
366
367/// Serializes all prepared meshes as `{ "entries": [{ "entity", "topology", "mesh" }] }` JSON.
368#[unsafe(no_mangle)]
369pub extern "C" fn kj_runtime_meshes_json(
370    handle: KjHandle,
371    output: *mut u8,
372    capacity: usize,
373    required: *mut usize,
374    error: *mut u8,
375    error_capacity: usize,
376    error_required: *mut usize,
377) -> KjStatus {
378    query(
379        handle,
380        OutputBuffer {
381            output,
382            capacity,
383            required,
384        },
385        ErrorBuffer {
386            output: error,
387            capacity: error_capacity,
388            required: error_required,
389        },
390        |runtime| {
391            let entries = runtime
392                .meshes()
393                .iter()
394                .map(|(entity, mesh)| {
395                    let is_line = runtime
396                        .scene()
397                        .entities
398                        .iter()
399                        .find(|candidate| candidate.id == *entity)
400                        .is_some_and(|candidate| {
401                            matches!(&candidate.geometry, Geometry::Line { .. })
402                        });
403                    let topology = if is_line {
404                        MeshTopology::Lines
405                    } else {
406                        MeshTopology::Triangles
407                    };
408                    MeshEntry {
409                        entity,
410                        topology,
411                        mesh,
412                    }
413                })
414                .collect();
415            serde_json::to_vec(&MeshBatch { entries }).map_err(|error| error.to_string())
416        },
417    )
418}
419
420/// Samples a deterministic frame and serializes it as UTF-8 JSON.
421#[unsafe(no_mangle)]
422pub extern "C" fn kj_runtime_sample_json(
423    handle: KjHandle,
424    time_seconds: f64,
425    output: *mut u8,
426    capacity: usize,
427    required: *mut usize,
428    error: *mut u8,
429    error_capacity: usize,
430    error_required: *mut usize,
431) -> KjStatus {
432    query(
433        handle,
434        OutputBuffer {
435            output,
436            capacity,
437            required,
438        },
439        ErrorBuffer {
440            output: error,
441            capacity: error_capacity,
442            required: error_required,
443        },
444        |runtime| {
445            runtime
446                .sample(time_seconds)
447                .and_then(|frame| {
448                    serde_json::to_vec(&frame).map_err(|error| konjure_sdk::Diagnostic {
449                        message: error.to_string(),
450                        span: None,
451                    })
452                })
453                .map_err(|error| error.to_string())
454        },
455    )
456}
457
458/// Dispatches a serialized `Event` and serializes its receipt as UTF-8 JSON.
459///
460/// # Safety
461/// `event` must describe a readable UTF-8 byte region of `event_len` bytes when nonempty.
462#[unsafe(no_mangle)]
463pub unsafe extern "C" fn kj_runtime_dispatch_json(
464    handle: KjHandle,
465    event: *const u8,
466    event_len: usize,
467    output: *mut u8,
468    capacity: usize,
469    required: *mut usize,
470    error: *mut u8,
471    error_capacity: usize,
472    error_required: *mut usize,
473) -> KjStatus {
474    contained(error, error_capacity, error_required, || {
475        let event = read_utf8(event, event_len, MAX_JSON_BYTES)
476            .map_err(|status| (status, "event must be valid bounded UTF-8".into()))?;
477        let event: Event = serde_json::from_str(event)
478            .map_err(|error| (KjStatus::InvalidArgument, error.to_string()))?;
479        if required.is_null() {
480            return Err((KjStatus::InvalidArgument, "required is required".into()));
481        }
482        // SAFETY: `required` was checked non-null and is caller-owned writable storage.
483        unsafe {
484            *required = DISPATCH_RECEIPT_CAPACITY;
485        }
486        if output.is_null() || capacity < DISPATCH_RECEIPT_CAPACITY {
487            return Ok(KjStatus::BufferTooSmall);
488        }
489        let mut registry = registry()
490            .lock()
491            .map_err(|_| (KjStatus::InternalError, "runtime registry poisoned".into()))?;
492        let runtime = registry.get_mut(handle).ok_or((
493            KjStatus::InvalidHandle,
494            "invalid or disposed runtime handle".into(),
495        ))?;
496        serialize(
497            &runtime
498                .dispatch(event)
499                .map_err(|error| (KjStatus::RuntimeError, error.to_string()))?,
500            output,
501            capacity,
502            required,
503        )
504    })
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    const SOURCE: &str =
512        "scene orbit { node star { sphere 0.2m; color #ffffff; action pause toggle_motion; } }";
513
514    fn compile_runtime() -> KjHandle {
515        let mut handle = KjHandle::default();
516        let mut error_len = 0;
517        assert_eq!(
518            unsafe {
519                kj_runtime_compile(
520                    SOURCE.as_ptr(),
521                    SOURCE.len(),
522                    &mut handle,
523                    std::ptr::null_mut(),
524                    0,
525                    &mut error_len,
526                )
527            },
528            KjStatus::Ok
529        );
530        handle
531    }
532
533    #[test]
534    fn abi_and_two_pass_buffers_are_stable() {
535        assert_eq!(kj_abi_version(), KJ_ABI_VERSION);
536        let handle = compile_runtime();
537        let mut required = 0;
538        let mut error_len = 0;
539        assert_eq!(
540            kj_runtime_sample_json(
541                handle,
542                0.0,
543                std::ptr::null_mut(),
544                0,
545                &mut required,
546                std::ptr::null_mut(),
547                0,
548                &mut error_len
549            ),
550            KjStatus::BufferTooSmall
551        );
552        let mut buffer = vec![0; required];
553        assert_eq!(
554            kj_runtime_sample_json(
555                handle,
556                0.0,
557                buffer.as_mut_ptr(),
558                buffer.len(),
559                &mut required,
560                std::ptr::null_mut(),
561                0,
562                &mut error_len
563            ),
564            KjStatus::Ok
565        );
566        assert!(
567            std::str::from_utf8(&buffer)
568                .unwrap()
569                .contains("time_seconds")
570        );
571        assert_eq!(
572            kj_runtime_dispose(handle, std::ptr::null_mut(), 0, &mut error_len),
573            KjStatus::Ok
574        );
575    }
576
577    #[test]
578    fn rejects_bad_handles_and_double_dispose() {
579        let handle = KjHandle {
580            slot: 99,
581            generation: 1,
582        };
583        let mut error_len = 0;
584        assert_eq!(
585            kj_runtime_dispose(handle, std::ptr::null_mut(), 0, &mut error_len),
586            KjStatus::InvalidHandle
587        );
588        let handle = compile_runtime();
589        assert_eq!(
590            kj_runtime_dispose(handle, std::ptr::null_mut(), 0, &mut error_len),
591            KjStatus::Ok
592        );
593        assert_eq!(
594            kj_runtime_dispose(handle, std::ptr::null_mut(), 0, &mut error_len),
595            KjStatus::InvalidHandle
596        );
597    }
598
599    #[test]
600    fn reports_invalid_utf8_per_call() {
601        let invalid = [0xff];
602        let mut handle = KjHandle::default();
603        let mut error_len = 0;
604        assert_eq!(
605            unsafe {
606                kj_runtime_compile(
607                    invalid.as_ptr(),
608                    invalid.len(),
609                    &mut handle,
610                    std::ptr::null_mut(),
611                    0,
612                    &mut error_len,
613                )
614            },
615            KjStatus::InvalidArgument
616        );
617        assert!(error_len > 0);
618    }
619
620    #[test]
621    fn accepts_null_empty_input_without_undefined_behavior() {
622        let mut handle = KjHandle::default();
623        let mut error_len = 0;
624        assert_eq!(
625            unsafe {
626                kj_runtime_compile(
627                    std::ptr::null(),
628                    0,
629                    &mut handle,
630                    std::ptr::null_mut(),
631                    0,
632                    &mut error_len,
633                )
634            },
635            KjStatus::CompileError
636        );
637        assert!(error_len > 0);
638    }
639
640    #[test]
641    fn undersized_dispatch_output_does_not_apply_the_event() {
642        let handle = compile_runtime();
643        let mut required = 0;
644        let mut error_len = 0;
645        let event = br#"{"kind":"invoke_action","entity":"star","action":"pause"}"#;
646        assert_eq!(
647            unsafe {
648                kj_runtime_dispatch_json(
649                    handle,
650                    event.as_ptr(),
651                    event.len(),
652                    std::ptr::null_mut(),
653                    0,
654                    &mut required,
655                    std::ptr::null_mut(),
656                    0,
657                    &mut error_len,
658                )
659            },
660            KjStatus::BufferTooSmall
661        );
662        assert_eq!(required, DISPATCH_RECEIPT_CAPACITY);
663        let mut frame_required = 0;
664        assert_eq!(
665            kj_runtime_sample_json(
666                handle,
667                0.0,
668                std::ptr::null_mut(),
669                0,
670                &mut frame_required,
671                std::ptr::null_mut(),
672                0,
673                &mut error_len
674            ),
675            KjStatus::BufferTooSmall
676        );
677        let mut frame = vec![0; frame_required];
678        assert_eq!(
679            kj_runtime_sample_json(
680                handle,
681                0.0,
682                frame.as_mut_ptr(),
683                frame.len(),
684                &mut frame_required,
685                std::ptr::null_mut(),
686                0,
687                &mut error_len
688            ),
689            KjStatus::Ok
690        );
691        assert!(
692            !std::str::from_utf8(&frame)
693                .unwrap()
694                .contains("action_applied")
695        );
696        let mut receipt = vec![0; required];
697        assert_eq!(
698            unsafe {
699                kj_runtime_dispatch_json(
700                    handle,
701                    event.as_ptr(),
702                    event.len(),
703                    receipt.as_mut_ptr(),
704                    receipt.len(),
705                    &mut required,
706                    std::ptr::null_mut(),
707                    0,
708                    &mut error_len,
709                )
710            },
711            KjStatus::Ok
712        );
713        assert!(
714            std::str::from_utf8(&receipt[..required])
715                .unwrap()
716                .contains("action_applied")
717        );
718    }
719}