Guide / Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

What Does a Copyable Signal Actually Copy?

In this guide

Download the Rust and MoonBit experiments. Extract the ZIP and follow framework-internals/README.md. Tested with Rust 1.96.0, Moon CLI 0.1.20260827, moonc 0.10.11+6ff76a5f9, Node 22.22.1, Python 3.12 and a native C toolchain. Initial builds fetch dependencies. Both SQLite examples use separate in-memory databases; no server is needed.

python3 verify.py

Two closures, one value

You want two Rust callbacks to share a changing value, without writing clone before every move closure. A copyable Signal gives both callbacks a handle to the same stored value. Copying that handle does not clone the payload, but asking for an owned value with get still can.

We will count those payload clones, update through a second callback, and then remove the owner. The example pins Reinhardt Core to version zero point four, alpha thirteen, the reactive core used by Reinhardt Pages. Version zero point three point fifteen uses a different, reference-counted design, so this explanation is deliberately version specific.

The scope owns the value

Start with the lifetime of the value, because copying a handle must not make ownership disappear. A reactive scope owns storage slots, and a Signal carries the key needed to find one slot. The key records the scope, slot index, generation, node identity, node kind, and owning thread information.

This is more than a raw pointer, and the copied handle does not independently own the payload. An access goes through the scope lookup and checks that the key still refers to live storage of the expected type. These checks let an old handle fail instead of silently reading unrelated state after cleanup.

The source uses thread-local ownership, so these handles are neither Send nor Sync, even for an integer payload. Moving work to a worker therefore needs an explicit message or another sharing design.

Copy the handle into callbacks

Now read the Rust example with that ownership picture in mind. We create a payload containing a string and a counter that increases whenever its Clone implementation runs. The counter measures payload cloning only; it is not an allocation counter or a stopwatch.

Inside scope enter, the first move closure calls get and the second calls update. Both closures capture the same Signal by copying its key, and the original binding remains usable. A compile-time Copy assertion verifies that property, while a zero counter verifies that capturing the handles did not clone our payload.

This convenience removes repeated handle-cloning syntax; it does not make string ownership free. Keep that distinction visible when reviewing code that reads a large collection from a signal.

Excerpt from the executable source; the complete program is in the download.

let read = move || signal.get();
let edit = move || signal.update(|p| p.text.push('!'));
assert_eq!(clones.get(), 0);
println!("handle_captures=2 payload_clones=0");
let owned = read();

Count the operations

Run the provided command and look at the first three output lines. Two handle captures produce zero payload clones in this experiment. Calling the read closure returns an owned payload containing hello and increases the clone counter to one.

The update closure appends an exclamation mark directly to the stored string. We inspect its new length through Signal::with_untracked, and confirm a length of six with no additional payload clone. That borrowed inspection does not register a reactive dependency, which matters for the next step.

We then create an effect that calls get, update once, and explicitly flush the pending work. Its execution count becomes two: the initial run and the run after the update.

Rust / Reinhardt

handle_captures=2 payload_clones=0
owned_get=1 payload_clones=1
update_and_borrow length=6 extra_payload_clones=0
tracked_effect_runs=2 after_one_update_and_flush
disposed_read=rejected disposed_write=rejected
PASS reinhardt-core=0.4.0-alpha.13; native; no timing benchmark

MoonBit

handle_captures=2 payload_clones=0
owned_get=1 payload_clones=1
update_and_borrow length=6 extra_payload_clones=0
tracked_effect_runs=2 after_one_update_and_flush
disposed_read=rejected disposed_write=rejected
PASS MoonBit arena; explicit payload copy; stale generation and dynamic edges

Borrowing changes the contract

The effect connects reading to future work: a tracked get records which observer depends on this signal. A write notifies the reactive runtime, which can schedule the observers that depend on the changed node. Our explicit flush makes the demonstration deterministic; it does not measure browser scheduling or paint time.

Replacing every get with Signal::with_untracked would remove the payload clone but also remove dependency tracking from that read. That can leave a display stale, so treat it as a semantic choice rather than an automatic optimization. Use update when you need to mutate the stored value, and use untracked inspection when no subscription is intended.

A copied handle, an owned snapshot, and a subscription answer three separate questions in your program.

A copied key can outlive its owner

Finally we dispose the scope while retaining a copied handle in an ordinary Rust variable. The variable still exists, but the value it used to reference has lost its owner. The example checks that both a fallible read and a fallible write are rejected after disposal.

This is the useful boundary for an asynchronous reply arriving after a component has disappeared. Use the fallible API to handle that situation; an ordinary get on a disposed node panics in this release. The scope must stay alive for as long as the application intends to use the reactive state.

A Copy type can still carry a lifetime rule enforced at runtime, rather than a Rust borrow lifetime.

Reproduce the mechanism in MoonBit

Now reproduce those observations in MoonBit, starting with an arena that owns the values. Our handle contains that arena, a slot index, and a generation number. An explicit copy function duplicates the array only when an owned read asks for it.

The executed checks reproduce zero copies for captures, one for get, and no extra copy for update and inspection. After disposal we reuse the slot, and the old generation still rejects reads and writes. This single-owner model omits Reinhardt's nested scopes and thread checks; MoonBit assignment is not Rust's Copy trait.

Run both versions, then follow the linked source to investigate one omitted boundary.

Excerpt from the executable source; the complete program is in the download.

pub struct Signal[T] {
  runtime : Runtime[T]
  index : Int
  generation : Int
}

Source map and limits

PORTING.md maps the pinned framework functions to the executable MoonBit implementation and names the omitted features. The reproduction uses different runtime representations and is a bounded investigation, not a complete port. Node 22 emits an experimental warning for its SQLite API.

Reinhardt contribution guide.