oficina / docs Public

core: C ABI for the wazero host

Handles are slab indices rather than raw pointers, so a stale handle
from the host is a null lookup instead of a wild dereference. Garbage
input returns an error code rather than trapping: §5.2 treats a trap as
poisoning the instance's memory, so a trap costs a document rebuild
where an error code costs nothing.

Two fixes to the brief's listed code, both needed to compile against
the pinned toolchain/lints:
- abi.rs imports yrs::Options (used by with_guid_and_client_id) —
  the brief's use line only imported Doc.
- The four unsafe extern "C" functions each get a `# Safety` doc
  section; clippy::missing_safety_doc is deny-by-default under
  scripts/build-wasm.sh's -D warnings.

wire_compat.rs's new tests are adjusted to match what's actually in
this crate rather than the brief's Step 1 listing: doc_new takes a
guid and client_id (amendment 1 — never drawn from yrs), and
fixture() returns a 3-tuple (update, text, delta), not 2.
Paul Campbell pushed by paul@keymail.dev b3d3e42190ff767d5f743acd11d22ef24a29ce5b parent 6e8eefb
3 files changed, +174 −0
  • core/src/abi.rs +141 −0
  • core/src/lib.rs +2 −0
  • core/tests/wire_compat.rs +31 −0
diff --git a/core/src/abi.rs b/core/src/abi.rs
new file mode 100644
index 0000000..5d8927c
--- /dev/null
+++ b/core/src/abi.rs
@@ -0,0 +1,141 @@
+//! The C ABI the Go host calls through wazero. No wasm-bindgen: wazero
+//! provides no JS glue, so everything crosses as integers and linear-memory
+//! offsets.
+//!
+//! Handles are indices into a slab rather than raw pointers, so a stale
+//! handle from the host is a null lookup instead of a wild dereference.
+
+use crate::{apply_update, text_content, CoreError};
+use std::cell::RefCell;
+use std::collections::HashMap;
+use yrs::{Doc, Options};
+
+thread_local! {
+ static DOCS: RefCell<HashMap<u32, Doc>> = RefCell::new(HashMap::new());
+ static NEXT: RefCell<u32> = const { RefCell::new(1) };
+}
+
+pub const ERR_NO_DOC: i32 = -1;
+pub const ERR_DECODE: i32 = -2;
+pub const ERR_APPLY: i32 = -3;
+pub const ERR_BUFFER_TOO_SMALL: i32 = -4;
+
+pub fn doc_new(guid: &str, client_id: u64) -> u32 {
+ let id = NEXT.with(|n| {
+ let mut n = n.borrow_mut();
+ let id = *n;
+ *n += 1;
+ id
+ });
+ let doc = Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id));
+ DOCS.with(|d| d.borrow_mut().insert(id, doc));
+ id
+}
+
+pub fn doc_free(handle: u32) {
+ DOCS.with(|d| d.borrow_mut().remove(&handle));
+}
+
+pub fn apply_slice(handle: u32, update: &[u8]) -> i32 {
+ DOCS.with(|d| match d.borrow().get(&handle) {
+ None => ERR_NO_DOC,
+ Some(doc) => match apply_update(doc, update) {
+ Ok(()) => 0,
+ Err(CoreError::Decode(_)) => ERR_DECODE,
+ Err(CoreError::Apply(_)) => ERR_APPLY,
+ },
+ })
+}
+
+pub fn text_string(handle: u32) -> String {
+ DOCS.with(|d| match d.borrow().get(&handle) {
+ None => String::new(),
+ Some(doc) => text_content(doc),
+ })
+}
+
+// --- wasm exports ---
+
+// Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity
+// then Vec::from_raw_parts(ptr, 0, len) — is undefined behaviour: with_capacity
+// guarantees AT LEAST len, so reconstructing with capacity `len` is wrong
+// whenever the allocator rounded up. std::alloc with an identical Layout on
+// both sides has no such gap.
+
+fn layout_for(len: u32) -> std::alloc::Layout {
+ // align 1: these are byte buffers the host writes into.
+ std::alloc::Layout::from_size_align(len.max(1) as usize, 1)
+ .expect("byte layout is always valid")
+}
+
+#[no_mangle]
+pub extern "C" fn docs_core_alloc(len: u32) -> u32 {
+ unsafe { std::alloc::alloc(layout_for(len)) as u32 }
+}
+
+/// # Safety
+///
+/// `ptr` must be either 0 (a no-op) or a value previously returned by
+/// `docs_core_alloc`, not yet freed, and `len` must be exactly the `len`
+/// that allocation was made with — `layout_for(len)` must reproduce the
+/// same `Layout` used to allocate it, per `std::alloc::dealloc`'s contract.
+#[no_mangle]
+pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) {
+ if ptr != 0 {
+ std::alloc::dealloc(ptr as *mut u8, layout_for(len));
+ }
+}
+
+/// # Safety
+///
+/// `guid_ptr` and `guid_len` must describe a byte range that lies entirely
+/// within this module's linear memory and is not mutated for the duration
+/// of this call.
+#[no_mangle]
+pub unsafe extern "C" fn docs_core_doc_new(
+ guid_ptr: u32,
+ guid_len: u32,
+ client_id: u64,
+) -> u32 {
+ let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize);
+ match std::str::from_utf8(bytes) {
+ Ok(guid) => doc_new(guid, client_id),
+ Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap
+ }
+}
+
+#[no_mangle]
+pub extern "C" fn docs_core_doc_free(handle: u32) {
+ doc_free(handle)
+}
+
+/// # Safety
+///
+/// `ptr` and `len` must describe a byte range that lies entirely within
+/// this module's linear memory and is not mutated for the duration of this
+/// call.
+#[no_mangle]
+pub unsafe extern "C" fn docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32 {
+ let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize);
+ apply_slice(handle, bytes)
+}
+
+#[no_mangle]
+pub extern "C" fn docs_core_text_len(handle: u32) -> u32 {
+ text_string(handle).len() as u32
+}
+
+/// # Safety
+///
+/// `out_ptr` and `out_len` must describe a byte range that lies entirely
+/// within this module's linear memory and is valid for writes for the
+/// duration of this call.
+#[no_mangle]
+pub unsafe extern "C" fn docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
+ let s = text_string(handle);
+ if s.len() > out_len as usize {
+ return ERR_BUFFER_TOO_SMALL;
+ }
+ std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len());
+ s.len() as i32
+}
diff --git a/core/src/lib.rs b/core/src/lib.rs
index 21f4e70..1d9969e 100644
--- a/core/src/lib.rs
+++ b/core/src/lib.rs
@@ -53,6 +53,8 @@
//! This crate avoids the whole problem by supplying both explicitly (see
//! above) rather than relying on `fastrand` to fail loudly when it can't —
//! it won't.
+pub mod abi;
+
#[cfg(target_arch = "wasm32")]
#[no_mangle]
extern "Rust" fn __getrandom_v03_custom(
diff --git a/core/tests/wire_compat.rs b/core/tests/wire_compat.rs
index 7ae9471..21d4adc 100644
--- a/core/tests/wire_compat.rs
+++ b/core/tests/wire_compat.rs
@@ -131,3 +131,34 @@ fn applies_formatted() {
fn applies_concurrent() {
check("concurrent");
}
+
+// Note: the brief's Step 1 listing calls `docs_core::abi::doc_new()` with no
+// arguments and destructures `fixture()` into a 2-tuple. Neither matches
+// what's actually in this file: `doc_new` takes the GUID and client id from
+// the host (amendment 1 — never drawn from `yrs`, see core/src/lib.rs), and
+// `fixture()` above returns a 3-tuple (update, text, delta). Adjusted here
+// to match; the round-trip and error-code behaviour the brief cares about
+// are unchanged.
+#[test]
+fn abi_round_trips_a_fixture() {
+ let (update, expected, _delta) = fixture("unicode");
+
+ let handle = docs_core::abi::doc_new("abi-round-trips-a-fixture-guid", 42);
+ assert_ne!(handle, 0, "doc_new returned a null handle");
+
+ let rc = docs_core::abi::apply_slice(handle, &update);
+ assert_eq!(rc, 0, "apply returned {rc}");
+
+ let got = docs_core::abi::text_string(handle);
+ assert_eq!(got, expected);
+
+ docs_core::abi::doc_free(handle);
+}
+
+#[test]
+fn abi_rejects_garbage_without_panicking() {
+ let handle = docs_core::abi::doc_new("abi-rejects-garbage-guid", 43);
+ let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]);
+ assert!(rc < 0, "garbage input should return an error code, got {rc}");
+ docs_core::abi::doc_free(handle);
+}