Stream: ideas

Topic: Reworking Rust Glue


view this post on Zulip Karl (Jul 31 2026 at 04:48):

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.

view this post on Zulip Karl (Jul 31 2026 at 04:49):

I didn't explicitly restrict things to just the glue layer so a couple compiler feature requests grew out of the session:

1. Retain the source alias name for structural types

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.roc declares ArgPod : { kind : U8, a : I32, b : I32 }. The host writes abi::AnonStruct17fa5d8cd542867 { kind, a, b } in eight places — the name the author gave it is discarded between declaration and emission.

2. Expose the erased-callable argument-struct layout

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 u8 into callable_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.

3. Export an ABI fingerprint symbol from compiled apps

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 "Element moves if Info grows — a glue regen and an ABI change," and its staleness check lives in the consumer's Justfile (stat the roc binary, shasum the platform sources). Nothing on the roc side lets a host detect that its bindings no longer match the app.

4. (Optional) A warning channel for glue scripts

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.

5. (Small) Pin "host-visible allocations use atomic refcounts" as a test

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.

view this post on Zulip Karl (Jul 31 2026 at 04:50):

Overall Design

Memory & ownership

Runtime

Types & naming

Tag unions

Callables & construction

Errors

Boundary & build

Scope

Compiler-side work (alias retention, fingerprint, teardown reachability) for all three emitters; the Rust API redesign Rust-only.

Defects found, independent of the redesign

  1. Tag identifiers bypass keyword escaping; keyword list missing 12 reserved words — a Roc tag named Try fails to compile.
  2. rustfmt_skip is the pre-2018 spelling, inert.
  3. Union rounding vs disc_offset diverges on x86-64.
  4. make_roc_host defaults to a different allocator than the host's own symbols.
  5. Unnamed records and unnamed multi-variant unions emit "" with no compile_error!.

Accepted losses

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.

Open

One item: whether Zig and C adopt equivalents of the view/constructor surface, or stop at the Phase 1 metadata fixes.

view this post on Zulip Karl (Jul 31 2026 at 04:51):

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.

view this post on Zulip Karl (Jul 31 2026 at 04:53):

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.

Today

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(),
    }
}

After — platform unchanged (positional payloads)

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 unsafen._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.

After — with record payloads

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.

view this post on Zulip Karl (Jul 31 2026 at 04:54):

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.

view this post on Zulip Karl (Jul 31 2026 at 04:54):

That should be enough to get the idea across.

view this post on Zulip Luke Boswell (Jul 31 2026 at 05:08):

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.

view this post on Zulip Luke Boswell (Jul 31 2026 at 05:09):

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

view this post on Zulip Karl (Jul 31 2026 at 05:10):

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.

view this post on Zulip Luke Boswell (Jul 31 2026 at 05:31):

Karl said:

named roc-solid because 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