docs / theater

Contents

Self-contained actor recipe

⚠️ RETIRED as of packr 0.11.0 — this fixed-base + composition recipe no longer applies.

packr 0.11.0 removed all composition/fuse machinery (packr::link, DEFAULT_ALLOCATOR_WASM, the --import-memory fixed-base recipe, theater compose). An actor is now a plain cargo build:

# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
packr-guest = "0.11.0"          # setup_guest!() links dlmalloc in
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = [
    "-C", "link-arg=--export-memory",
    "-C", "link-arg=--no-entry",
]
cargo build --target wasm32-unknown-unknown --release
# the resulting <name>.wasm is directly loadable — no compose step.

The cdylib exports its own growable memory + __pack_alloc/__pack_free + lifecycle and imports only host theater:simple/*. theater build --release still verifies the import surface. Worked example: test-actors/state-test, driven in CI by the Reference actor (self-contained e2e) job. The historical 0.10.2 composite recipe below is kept for reference only.


Self-contained actor recipe (packr 0.10.2) — HISTORICAL

The canonical, copy-me reference for building a fleet actor against the packr 0.10.2 self-contained model (PIC removed). Worked example: test-actors/state-test (a single-package message/runtime actor), driven end-to-end in CI by the Reference actor (self-contained e2e) job in .github/workflows/ci.yml.

Why a composite

theater's 0.10.x loader does assert_self_contained. A bare cargo-built member (imported memory, unresolved pack:alloc, __pack_types surface) is not self-contained and will not load. The deployable artifact is a self-contained composite = the actor member + the packr bundled allocator, fused so memory + pack:alloc are internalized and only the host theater:simple/* imports remain residual. theater build --release produces and verifies it in one command — no Rust link harness.

1. Cargo.toml

[lib]
crate-type = ["cdylib"]

[dependencies]
# Drop the `pic` feature. Two cases:
#   - Value-style state (build Value::Record by hand, like state-test): bare, no features.
packr-guest = "0.10.2"
#   - #[derive(GraphValue)] typed-state (most 0.7/0.8-era actors): KEEP the derive feature.
# packr-guest = { version = "0.10.2", features = ["derive"] }

[profile.release]
opt-level = "s"
lto = true                    # keep the member small

derive vs bare. GraphValue is #[cfg(feature = "derive")], and derive is non-default in 0.10.2 (it doesn't show on docs.rs). If your actor uses #[derive(GraphValue)] / #[derive(Clone, GraphValue)] #[graph(crate = "packr_guest::composite_abi")] on its state, keep features = ["derive"] — that alone is the migration (no source rewrite). Symptom if you forget it: E0432 unresolved import GraphValue → E0277 Value: From<State> not satisfied. Only actors that hand-build Value::Record (like the state-test example below) stay bare. derive is confirmed working under the self-contained model (shipped by tickets-ui).

2. .cargo/config.toml — the fixed-base recipe

Replaces the old PIC flags entirely (no relocation-model=pic, --experimental-pic, -shared, --export=__wasm_call_ctors):

[target.wasm32-unknown-unknown]
rustflags = [
    "-C", "link-arg=--import-memory",
    "-C", "link-arg=--initial-memory=8388608",
    "-C", "link-arg=--stack-first",
    "-C", "link-arg=-zstack-size=262144",
    "-C", "link-arg=--global-base=327680",
    "-C", "link-arg=--no-entry",
    "-C", "link-arg=--no-merge-data-segments",
]

3. Handler ABI

Flat/positional (0.8+): fn handler(state, ..params) — each pact param is its own Value arg, and fn init(state). Not a wrapped (state, params) tuple.

4. Build (compose + verify in one command)

theater build --release <actor-dir>

This runs cargo build --target wasm32-unknown-unknown --release, then links the member + packr::DEFAULT_ALLOCATOR_WASM into <name>.composite.wasm, verifies it (below), and rewrites manifest.toml package = … to point at the composite. Requires wasm-merge (binaryen) and wasm-tools on PATH — both are in the theater dev shell (nix develop). --no-compose emits the bare member only (not loadable; debugging).

Workspace member? theater build looks for the member wasm at <actor-dir>/target/…, but a cargo workspace builds to the workspace-root target/, so theater build <member-dir> finds nothing. If you still want the one-command path, redirect cargo's output to where theater build looks:

CARGO_TARGET_DIR="$PWD/<member-dir>/target" theater build --release <member-dir>

Or use theater compose on the prebuilt member (next section) — better for crane/offline builds that don't run theater build at all.

Crane / cargo-workspace builds → theater compose

theater build re-runs cargo and assumes a standalone crate (its own target/ dir). It does not fit a crane flake or a cargo workspace member (whose target/ is at the workspace root — see the CARGO_TARGET_DIR note above if you want to keep using theater build). For crane / offline / sandboxed builds, build the member however you already do (crane buildPackage, offline/sandboxed cargo, …), then compose the prebuilt member with the standalone subcommand:

theater compose <member.wasm> [-o <name>.composite.wasm]     # verifies by default; --no-verify to skip

It links the member + the bundled allocator (single-package, base 0x50000), runs the same verification, writes the composite, and prints its path to stdout. Same PATH tools (wasm-merge, wasm-tools). Typical crane installPhase: crane builds the bare members → theater compose each → install the composites. Deploy the composite, never the bare member.

5. Verify (done automatically by theater build; manual check)

wasm-tools validate <name>.composite.wasm
# every residual import must be a host theater:simple/* function:
wasm-tools print <name>.composite.wasm | grep '(import' | grep -v 'theater:simple/'   # must print NOTHING

Any (import "env" "memory" …), pack:alloc, or __linear_memory means the allocator/memory was not internalized (a bare member, or the fixed-base recipe was not applied). theater build fails the build on this.

6. Run

theater spawn <actor-dir>/manifest.toml      # loads the composite (assert_self_contained) + runs init

theater spawn creates the runtime in-process, loads the composite through the 0.10.x self-contained loader, and calls actor.init. If the composite is not self-contained, it fails here with a load error rather than starting.

Crane / Nix notes (if building actors under crane)

Deploy model

The 0.8.1→0.10.2 cutover is one atomic per-process flip (the theater binary + all its actors together; mixed won't load). After cutover, self-contained relaxes to per-actor deploys: a single actor's composite can be rebuilt and redeployed without touching the binary or sibling actors, as long as the theater:simple/* host pact ABI and the packr-abi wire format are unchanged.