The current Rust glue doesn't really take advantage of Rust's features and leaves the door open for errors that those features prevent. The GUI platform I'm working on (named roc-solid because it was going to be a Solid port; needs a rename) is up to ~30k lines of Rust and 3k LoC glue and tower-platform is 10.5k Rust and 3.7k glue so I feel they're large enough to exercise the glue reasonably well. I have had Claude do the wrong thing at several points along the way. Since I'm waiting on the weekly token reset tonight and design work is cheap I thought I'd take a swing at it tonight. This isn't particularly carefully considered and my total investment is a couple hours of thinking about it so I'm not particularly committed to anything.
Goals going in were to eliminate unsafe outside of generated code and to have the borrow checker earn its keep. I'm going to dump some LLM summaries and code examples and do some more formal process if there's interest. If not then I enjoyed thinking about it.
I didn't explicitly restrict things to just the glue layer so a couple compiler feature requests grew out of the session:
glue.zig walks through .alias nodes to reach the backing record and discards alias.name on the way (:2455, :2837, :4216, :4268). Keep it and hand emitters the module-qualified name. One field on RecordRepr; no layout change; all three emitters benefit.
platform-im/Element.rocdeclaresArgPod : { kind : U8, a : I32, b : I32 }. The host writesabi::AnonStruct17fa5d8cd542867 { kind, a, b }in eight places — the name the author gave it is discarded between declaration and emission.
Add the packed args AbiLayout (and the return layout) to FunctionRepr beside the existing type ids, so emitters can generate and assert the struct the ABI actually passes instead of reconstructing it.
Both platforms hand-declare
#[repr(C)] struct Args { … }and pass&args as *const u8intocallable_fn_ptr. roc-solid's hazards note records(Str, Bool, model -> model)SIGBUSing on a sub-word parameter in a non-final slot — a hand-written struct can't be checked against anything, and neither host has any way to know it guessed wrong.
Hash every layout fact the type table exposes and export a zero-sized symbol named for it; glue emits an opt-in roc_abi_assert!() referencing it, so a host built against stale bindings fails at link rather than at runtime.
roc-solid's standing hazards records "
Elementmoves ifInfogrows — a glue regen and an ABI change," and its staleness check lives in the consumer's Justfile (statthe roc binary, shasum the platform sources). Nothing on the roc side lets a host detect that its bindings no longer match the app.
make_glue : List(Types) -> Try(List(File), Str) lets a script fail the build but not warn, so there's no way to tell a platform author about a boundary shape that is legal but hazardous. Fallback needing no compiler change: emit the warning as a comment or #[deprecated] note in the generated source.
TextInput(Str, Str)— two payload slots the type system can't tell apart, so transposing them compiles silently in every host. Worth naming; not worth failing a build over.
arc.zig's visibility analysis picks single_thread only for allocations no host thread can touch (:4667). Generated glue is about to rely on that to declare Roc types Send/Sync by default. Add a test asserting boundary-reachable allocations always get .atomic.
tower-platform runs a multi-threaded tokio runtime and shares Roc handlers and the app model across workers behind seven hand-written
unsafe impl Send/Sync. That's sound because of the visibility rule — but the rule is currently an implementation property, not a pinned contract, and glue is about to extend it to every host.
Drop on refcounted leaves (RocStr, RocListWith<T>, RocBox<T>, RocErasedCallable) plus a generated Drop per tag union; records use derived drop glue. Rejected: explicit release helpers (chosen first, then revisited — Rust's drop glue is the reachability closure, so the gap can't reopen); Drop-bomb.Clone = incref uniformly, including generated records and tag unions. Rejected: Clone-on-leaves-only, which was hedging a cost that doesn't exist — Element::incref is ≤4 relaxed atomics, bounded by the static type.try_into_iter() -> Result<IntoIter, Self>, iter() for borrows. Rejected: clone-on-shared (silent cost cliff); no consuming iterator at all.roc_alloc/roc_dealloc linker symbols. Forced by Drop taking no parameters. Rejected: keeping the vtable (chosen first, incompatible with Drop); registered global host pointer; split symbol/vtable APIs — the two-paths-disagree shape.RocRuntime trait and roc_runtime!(Impl) macro. dbg/expect_failed/crashed take &[u8].SystemRuntime, methods directly callable. Rejected: DefaultRuntime (implies automatic and recommended, both false); TestingRuntime (used as a production building block).no_roc_std_helpers and unexpected_cfgs.alias.name at the four .alias drill-through sites. Module-qualified; canonical by (module, name) sort; others as pub type; each use site spelled with the alias reached there. Rejected: one canonical name spelled everywhere (BorderTok in an Element signature); hash-canonical with all names as aliases (hash leaks into rustc errors).unsafe trait RocElement, RocList<T: RocElement>, RocListRaw escape hatch.RocBox<T> newtype with safe deref.r# raw identifiers; fixing only the tag sites.[u8; disc_offset] + zero-sized alignment marker at both widths; union deleted; payload_x_unchecked() as escape hatch. Rejected: keeping the 64/32 split; union everywhere — Roc's unrounded payload area is inexpressible as a Rust union, and the divergence is reachable on x86-64.into_view(), constructors. Rejected: Option-returning accessors (no exhaustiveness); view enum without constructors._0/_1 otherwise, with a doc comment listing each slot's Roc type, and a warning scoped to variants with ≥2 slots of the same type. Rejected: tuple structs (padding shifts indices); per-position newtypes (Deref reintroduces the swap hazard, and without Deref reads become t._0.0); refusing positional payloads outright.store.zig, layout.zig, and the glue README — tag-last with an unrounded payload area is a space optimization (16 vs 24 bytes on the canonical example), not a convention.call(), args struct and return buffer from committed layout with AbiLayout assertions, Clone/Drop. Rejected: generated Args structs with a generic call helper; args-only typing.mem::zeroed replaced by constructors, roc_unreachable() -> !, and T::zeroed() emitted only for types with no refcounted fields.From<&str>, from_str, from_iter, new); try_* for the genuinely fallible. No panic path in generated code. Rejected: blanket Result (overscoped — RocOps.alloc already unwraps, so OOM is fatal by contract); abort-everywhere. [ASSUMPTION] null from roc_alloc aborts through roc_crashed.mod raw; every into_raw() lives inside them. Rejected: the AbiChecked token.roc_abi_assert!(), opt-in. Rejected: unconditional link-time symbol (breaks app-less builds); stub macro; hash-suffixed entrypoint names (blast radius across linker, shim, hot-reload).refcount(), element_stride(), is_seamless_slice().improper_ctypes allows scoped to mod raw. Single #[path] file, rustfmt::skip spelled correctly. Rejected: generated crate; rustfmt-clean emission.Compiler-side work (alias retention, fingerprint, teardown reachability) for all three emitters; the Rust API redesign Rust-only.
Try fails to compile.rustfmt_skip is the pre-2018 spelling, inert.disc_offset diverges on x86-64.make_roc_host defaults to a different allocator than the host's own symbols."" with no compile_error!.Implicit Copy duplication (the mechanism itself); deliberate leaks need mem::forget; per-platform helper allocators in one process; a host that skips roc_abi_assert!() gets no check; path-derived names churn on platform reorganization — loudly.
One item: whether Zig and C adopt equivalents of the view/constructor surface, or stop at the Phase 1 metadata fixes.
The 196 unsafe blocks break down as:
| category | count | fate |
|---|---|---|
decref / incref |
86 | gone — Drop / Clone |
&* box deref |
20 | gone — RocBox<T> derefs safely |
call_erased |
14 | gone — typed callable newtypes |
abi::roc_* entrypoint calls |
12 | gone — safe wrappers |
from_slice / allocate |
6 | gone — from_iter |
from_raw_parts |
7 | mostly gone — see below |
raw casts / ptr::read |
8 | mixed |
| remainder | ~55 | untouched — clay/wgpu FFI, nothing to do with Roc |
(Categories overlap slightly; a block can match two patterns.) Let me show what the survivors actually are.
Text(Info, List(Tok), Str),
Node(Str, Info, List(Tok), List(Box(Element))),
Button(Info, List(Tok), U64, List(Box(Element))),
Input(Info, List(Tok), U64, Str),
Rich(Info, List(Tok), List(Run)),
_0 is Info in every variant except Node, where it's Str. Same union, same slot index, different meaning — and nothing in the host says so.
fn label_of(el: &abi::Element) -> String {
match el.tag {
abi::ElementTag::Text => text_of(&el.payload_text()._2),
abi::ElementTag::Node => {
let n = el.payload_node();
// SAFETY: Box(Element) crosses as a live, parent-owned pointer.
n._3.as_slice().iter().map(|&c| label_of(unsafe { &*c })).collect()
}
abi::ElementTag::Button => {
let b = el.payload_button();
b._3.as_slice().iter().map(|&c| label_of(unsafe { &*c })).collect()
}
_ => String::new(),
}
}
Glue emits this above the payload struct, which is the legibility fix for the Node trap:
/// Payload for `Element.Node`.
/// _0: Str, _1: Info, _2: List(Tok), _3: List(Box(Element))
pub struct ElementNodePayload { pub _0: RocStr, pub _1: UiInfo, … }
and the walk becomes:
fn label_of(el: &Element) -> String {
match el.view() {
ElementView::Text(t) => text_of(&t._2),
ElementView::Node(n) => n._3.iter().map(|c| label_of(c)).collect(),
ElementView::Button(b) => b._3.iter().map(|c| label_of(c)).collect(),
ElementView::Input(_) | ElementView::Rich(_) | ElementView::Spacer(_) => String::new(),
}
}
No unsafe — n._3.iter() yields &RocBox<Element> and derefs at the call. Note |c| label_of(c) rather than .map(label_of): deref coercion fires at a call site, not when passing a function item. The _ => becomes an explicit list, so a sixth variant stops the build. And t is now a borrow of the payload rather than the copy payload_text() returns today — which under Drop types is the difference between correct and a double release.
Still _0/_1/_2 — better than today (no unsafe, exhaustive, doc comment on the page) but not good.
One change in Element.roc:
Text({ info : Info, styles : List(Tok), text : Str }),
Node({ label : Str, info : Info, styles : List(Tok), kids : List(Box(Element)) }),
Button({ info : Info, styles : List(Tok), handler : U64, kids : List(Box(Element)) }),
and the view enum gets struct variants:
fn label_of(el: &Element) -> String {
match el.view() {
ElementView::Text { text, .. } => text_of(text),
ElementView::Node { kids, .. } => kids.iter().map(|c| label_of(c)).collect(),
ElementView::Button { kids, .. } => kids.iter().map(|c| label_of(c)).collect(),
ElementView::Input { .. } | ElementView::Rich { .. } | ElementView::Spacer { .. } => String::new(),
}
}
Node's odd slot order stops mattering entirely — you bind info by name and the position is irrelevant. The Roc side improves identically, since app code destructures by name too.
One thing to know about styles.as_slice().to_vec(): under non-Copy types that's T: Clone, so it increfs each element. Tok is plain data, so its generated Clone is a memcpy with no atomics — free. It would not be free for a list of Str.
The handler call
// before
#[repr(C)] struct Args { arg0: RocRequest } // a guess at committed layout
unsafe { abi::incref_box(model.0, 1) };
let args = Args { arg0: RocRequest { ctx, headers: roc_kv_list(&parts.headers), … } };
let mut ret = core::mem::MaybeUninit::<RocResponse>::uninit();
unsafe {
let payload = abi::roc_erased_callable_payload_ptr(handler.0);
let capture = abi::roc_erased_callable_capture_ptr(handler.0);
((*payload).callable_fn_ptr)(host as *const _ as *mut _,
ret.as_mut_ptr() as *mut u8, &args as *const Args as *const u8, capture);
let mut response = ret.assume_init();
// Take the list out and neutralize the response's copy FIRST: `response.decref`
// below recursively decrefs the body field, and `RocListWith` is `Copy`, so
// without this an arena body would be a harmless double no-op but a >4 MiB
// cap-fallback (global) body would be genuinely freed out from under the owner.
let body_list = response.body;
response.body = abi::RocListWith::empty();
…
response.decref(host);
}
// after
let response = handler.call(RocRequest { ctx, model: model.clone(), headers: …, … });
let body_list = response.body; // partial move — the neutralization line is unwriteable
…
// remaining fields drop at scope end
That comment describes a double-free that only manifests above 4 MiB — a hazard that passes every small-payload test, which is the same shape as roc-solid's "a payload under 24 bytes cannot exhibit this." Under move semantics it isn't a hazard that's been fixed; it's a state that can't be expressed. Taking response.body out is a partial move, and Rust drops the remaining fields itself.
That should be enough to get the idea across.
I haven't read everything above ... but the general idea was certainly to make the generated code as idiomatic and nice to use as possible.
The reason we went with three languages to start with is because a) we needed those, but b) it would be easy to overfit the API for one language ... so having multiple helps us keep the API generic and suitable for multiple target languages
This is mostly a feature request and not a concrete proposal. I wanted to bring up the topic as soon as I felt like I could do it reasonably because glue changes are disruptive.
Karl said:
named
roc-solidbecause it was going to be a Solid port; needs a rename
This has to be the coolest name for a project... :rock: the only thing cooler might be solid-roc. Here's a very Australian song to help convince you...
https://youtu.be/tSNxFGW09Mo?si=PtRfEo74SpZS-V8R
Last updated: Aug 12 2026 at 12:35 UTC