rust — how Carbon writes Rust (tokio + heavy C++ FFI)
The workspace: crates/{occt-bridge,collision,converter,planner} (cxx bridges
to OpenCASCADE + FCL, geometry, motion planning) and apps/assembler (axum
service). The defining constraint: CPU-bound, non-yielding C++ behind async
HTTP — most rules below exist because of it. Several are measured facts from
this repo, not folklore.
Async/tokio discipline — the rules that carry this service
- Sync C++/CPU work never runs on an async worker. Tokio's ~cores worker
threads poll every socket; a non-awaiting FFI call pins one for its full
duration. Measured here: running convert inline at c=64 took
/health p99
from 7ms to 296ms — one full convert of starvation; real files block minutes.
Default: tokio::task::spawn_blocking(move || ffi_work()).await.
(block_in_place only when you must keep borrowing !Send locals; never on
a current-thread runtime.)
- The blocking pool is the queue. Cap it near core count
(
Builder::new_multi_thread().max_blocking_threads(n)) and excess
spawn_blocking tasks queue inside tokio — graceful backpressure with zero
429/queue code. apps/assembler/src/main.rs does exactly this; keep +2
headroom so tokio::fs ops don't starve behind long converts.
- Never hold a lock across
.await. House pattern: do all locking inside
sync/blocking scopes (apps/assembler/src/cache.rs — LRU behind
std::sync::Mutex, lock held for map ops only, never crosses an await).
Prefer std::sync::Mutex for short non-await sections (faster);
tokio::sync::Mutex only when a guard genuinely must cross an await —
restructure first.
- rayon stays off the tokio runtime. The planner's
par_iter sweeps run
inside spawn_blocking closures. Never .par_iter() in an async fn.
Remember rayon's pool is process-global: per-request fan-out multiplied by
concurrent requests oversubscribes — measured here, N single-threaded
requests across N cores beat N×all-core fan-outs under load
(ASSEMBLER_MESH_PARALLEL=0 exists for the same reason on the C++ side).
- Timeout ≠ cancellation for blocking work.
tokio::time::timeout stops
the await, not the FFI already running on the blocking pool. Bound the
work itself (early-stop callbacks — see the planner's FCL canStop()
threshold nodes) or accept run-to-completion.
- Channels:
oneshot to bridge a blocking/rayon result to a handler,
mpsc::channel(n) for bounded many→one, watch for latest-value config,
broadcast for shutdown fan-out. Bound all fan-out
(buffer_unordered(limit), Semaphore) — never unbounded spawn loops.
- Shutdown: prefer awaiting completion (
axum's
.with_graceful_shutdown(...), JoinSet::join_next()) over
cancel-then-sleep — a fixed sleep is fake-graceful.
Ownership & shared state — decision table
| Need |
Use |
In-repo example |
| Share read-mostly across tasks |
Arc<T> |
AppState fields |
| Concurrent map, sharded locks |
DashMap |
plan job store, progress store |
| Byte-bounded LRU |
Mutex<LruCache> (ns-held, sync scopes only) |
cache.rs |
| One-time global init |
OnceLock / std::sync::Once |
shared reqwest::Client; C++ std::call_once for OCCT init |
| Counters / phase flags |
atomics (AtomicU64, AtomicU8, relaxed) |
progress.rs |
| Lazy memo inside a struct |
OnceCell/OnceLock field |
Component::vol_cache, sym_axis_cache |
| Zero-copy payload sharing |
bytes::Bytes (clone = refcount) |
cached GLB/graph uploads |
Signatures take &str/&[T]; clone only for ownership transfer. _else
variants (unwrap_or_else, ok_or_else) when the eager argument allocates.
FFI (cxx) — the sharp edges
- Bridges live in
crates/occt-bridge (OCCT) and crates/collision (FCL),
via the cxx crate; C++ exceptions map to Result at the bridge.
- C++ types are
!Send/!Sync by default. Keep handles thread-confined
inside one spawn_blocking closure; move plain data (Vec/f64) out. When a
wrapper genuinely is shareable, unsafe impl Send/Sync with the
justification in a comment — in-repo precedent: SharedBvh and
CollisionWorld (immutable after construction, so concurrent reads are
sound). No justification comment = review reject.
- Global C++ state is real: OCCT config must be initialized once
(
std::call_once in occt.cc) — per-call mutation of process-global state
was this repo's original concurrency bug.
// SAFETY: comment on every unsafe block, no exceptions.
- Don't set
panic = "abort" without checking the C++ exception/unwind story
at the cxx boundary first.
Errors — the house convention (verified, not aspirational)
Carbon does not use thiserror/anyhow. Errors are hand-rolled structs
mirroring the wire contract: ConvertError { code, message }
(crates/converter), ApiError { status, code, message } (apps/assembler),
with From impls at the boundary. Follow that for service-contract errors.
thiserror is fine for a new library crate with no wire contract; never leak
anyhow from a library. ? over match chains; no unwrap/expect outside
tests and provably-infallible spots.
Performance — measured habits
- Measure, don't guess; always
--release. This repo's profiling loop:
macOS sample <pid> (worked where samply left hex frames), phase-timing via
env-gated eprintln! (OCCT_PROFILE=1), cargo build --profile profiling
(release + debug symbols, defined in the workspace Cargo.toml) for
symbolicated native stacks.
- Contention shows up as user time, not lock waits: zero
ulock/sys with
bad thread scaling = cache-line/allocator trouble. Discriminate software vs
hardware with the multi-process control (N processes vs N threads of the
same work — this exposed the OCCT allocator true-sharing bug).
- Allocation hygiene:
with_capacity when size is known; no intermediate
.collect() (pass impl Iterator); SmallVec only where measured (the
contacts type here — kept, measured as noise); memoize expensive derived
values in OnceCell fields rather than recomputing per iteration.
- Allocator:
tikv-jemallocator is Linux-only in apps/assembler —
measured a ~6% loss on macOS. Don't "fix" that gate.
#[inline], LTO, codegen-units=1: only with a benchmark proving them.
Tooling
cargo build --release -p assembler — workspace root; build.rs resolves
OCCT via OCCT_PREFIX → cached static build (~/.cache/carbon-occt/) →
brew (see apps/assembler/AGENTS.md).
- Tests: unit in-module; integration in
crates/*/tests/ — behavioral
synthetic-geometry tests (crates/planner/tests/synthetic_plan.rs) are the
model: build inputs in code, assert invariants, no fixtures/goldens.
Env-gated tests (ASSEMBLER_FIXTURES, ASSEMBLER_ASSEMBLIES) skip silently
when unset — fine for corpus tests, never for core logic.
cargo clippy --all-targets -- -D warnings before committing Rust;
#[expect(lint, reason = "...")] over blanket #[allow].
1---2name: rust3description: Rust reference for Carbon's cargo workspace (crates/* + apps/assembler) — tokio discipline for CPU-heavy C++ FFI, ownership/interior-mutability choices, rayon isolation, error conventions, performance and profiling, all grounded in measured results from this codebase. Use when writing or reviewing Rust in this repo, deciding spawn_blocking vs inline vs rayon, picking Mutex/RwLock/DashMap/OnceLock/atomics, or hunting allocations. Do not use for the assembler's HTTP/service contract or deployment — that is apps/assembler/AGENTS.md.4---56# rust — how Carbon writes Rust (tokio + heavy C++ FFI)78The workspace: `crates/{occt-bridge,collision,converter,planner}` (cxx bridges9to OpenCASCADE + FCL, geometry, motion planning) and `apps/assembler` (axum10service). The defining constraint: **CPU-bound, non-yielding C++ behind async11HTTP** — most rules below exist because of it. Several are measured facts from12this repo, not folklore.1314## Async/tokio discipline — the rules that carry this service15161. **Sync C++/CPU work never runs on an async worker.** Tokio's ~cores worker17 threads poll every socket; a non-awaiting FFI call pins one for its full18 duration. Measured here: running convert inline at c=64 took `/health` p9919 from 7ms to 296ms — one full convert of starvation; real files block minutes.20 Default: `tokio::task::spawn_blocking(move || ffi_work()).await`.21 (`block_in_place` only when you must keep borrowing `!Send` locals; never on22 a current-thread runtime.)232. **The blocking pool is the queue.** Cap it near core count24 (`Builder::new_multi_thread().max_blocking_threads(n)`) and excess25 `spawn_blocking` tasks queue inside tokio — graceful backpressure with zero26 429/queue code. `apps/assembler/src/main.rs` does exactly this; keep +227 headroom so `tokio::fs` ops don't starve behind long converts.283. **Never hold a lock across `.await`.** House pattern: do all locking inside29 sync/blocking scopes (`apps/assembler/src/cache.rs` — LRU behind30 `std::sync::Mutex`, lock held for map ops only, never crosses an await).31 Prefer `std::sync::Mutex` for short non-await sections (faster);32 `tokio::sync::Mutex` only when a guard genuinely must cross an await —33 restructure first.344. **rayon stays off the tokio runtime.** The planner's `par_iter` sweeps run35 inside `spawn_blocking` closures. Never `.par_iter()` in an `async fn`.36 Remember rayon's pool is process-global: per-request fan-out multiplied by37 concurrent requests oversubscribes — measured here, N single-threaded38 requests across N cores beat N×all-core fan-outs under load39 (`ASSEMBLER_MESH_PARALLEL=0` exists for the same reason on the C++ side).405. **Timeout ≠ cancellation for blocking work.** `tokio::time::timeout` stops41 the *await*, not the FFI already running on the blocking pool. Bound the42 work itself (early-stop callbacks — see the planner's FCL `canStop()`43 threshold nodes) or accept run-to-completion.446. Channels: `oneshot` to bridge a blocking/rayon result to a handler,45 `mpsc::channel(n)` for bounded many→one, `watch` for latest-value config,46 `broadcast` for shutdown fan-out. Bound all fan-out47 (`buffer_unordered(limit)`, `Semaphore`) — never unbounded `spawn` loops.487. Shutdown: prefer awaiting completion (`axum`'s49 `.with_graceful_shutdown(...)`, `JoinSet::join_next()`) over50 cancel-then-sleep — a fixed sleep is fake-graceful.5152## Ownership & shared state — decision table5354| Need | Use | In-repo example |55|---|---|---|56| Share read-mostly across tasks | `Arc<T>` | `AppState` fields |57| Concurrent map, sharded locks | `DashMap` | plan job store, progress store |58| Byte-bounded LRU | `Mutex<LruCache>` (ns-held, sync scopes only) | `cache.rs` |59| One-time global init | `OnceLock` / `std::sync::Once` | shared `reqwest::Client`; C++ `std::call_once` for OCCT init |60| Counters / phase flags | atomics (`AtomicU64`, `AtomicU8`, relaxed) | `progress.rs` |61| Lazy memo inside a struct | `OnceCell`/`OnceLock` field | `Component::vol_cache`, `sym_axis_cache` |62| Zero-copy payload sharing | `bytes::Bytes` (clone = refcount) | cached GLB/graph uploads |6364Signatures take `&str`/`&[T]`; clone only for ownership transfer. `_else`65variants (`unwrap_or_else`, `ok_or_else`) when the eager argument allocates.6667## FFI (cxx) — the sharp edges6869- Bridges live in `crates/occt-bridge` (OCCT) and `crates/collision` (FCL),70 via the `cxx` crate; C++ exceptions map to `Result` at the bridge.71- **C++ types are `!Send`/`!Sync` by default.** Keep handles thread-confined72 inside one `spawn_blocking` closure; move plain data (Vec/f64) out. When a73 wrapper genuinely is shareable, `unsafe impl Send/Sync` **with the74 justification in a comment** — in-repo precedent: `SharedBvh` and75 `CollisionWorld` (immutable after construction, so concurrent reads are76 sound). No justification comment = review reject.77- Global C++ state is real: OCCT config must be initialized **once**78 (`std::call_once` in `occt.cc`) — per-call mutation of process-global state79 was this repo's original concurrency bug.80- `// SAFETY:` comment on every `unsafe` block, no exceptions.81- Don't set `panic = "abort"` without checking the C++ exception/unwind story82 at the cxx boundary first.8384## Errors — the house convention (verified, not aspirational)8586Carbon does **not** use thiserror/anyhow. Errors are hand-rolled structs87mirroring the wire contract: `ConvertError { code, message }`88(`crates/converter`), `ApiError { status, code, message }` (`apps/assembler`),89with `From` impls at the boundary. Follow that for service-contract errors.90thiserror is fine for a *new* library crate with no wire contract; never leak91`anyhow` from a library. `?` over match chains; no `unwrap`/`expect` outside92tests and provably-infallible spots.9394## Performance — measured habits9596- **Measure, don't guess; always `--release`.** This repo's profiling loop:97 macOS `sample <pid>` (worked where samply left hex frames), phase-timing via98 env-gated `eprintln!` (`OCCT_PROFILE=1`), `cargo build --profile profiling`99 (release + debug symbols, defined in the workspace `Cargo.toml`) for100 symbolicated native stacks.101- Contention shows up as *user time*, not lock waits: zero `ulock`/`sys` with102 bad thread scaling = cache-line/allocator trouble. Discriminate software vs103 hardware with the multi-process control (N processes vs N threads of the104 same work — this exposed the OCCT allocator true-sharing bug).105- Allocation hygiene: `with_capacity` when size is known; no intermediate106 `.collect()` (pass `impl Iterator`); `SmallVec` only where measured (the107 contacts type here — kept, measured as noise); memoize expensive derived108 values in `OnceCell` fields rather than recomputing per iteration.109- Allocator: `tikv-jemallocator` is **Linux-only** in `apps/assembler` —110 measured a ~6% *loss* on macOS. Don't "fix" that gate.111- `#[inline]`, LTO, `codegen-units=1`: only with a benchmark proving them.112113## Tooling114115- `cargo build --release -p assembler` — workspace root; build.rs resolves116 OCCT via `OCCT_PREFIX` → cached static build (`~/.cache/carbon-occt/`) →117 brew (see `apps/assembler/AGENTS.md`).118- Tests: unit in-module; integration in `crates/*/tests/` — behavioral119 synthetic-geometry tests (`crates/planner/tests/synthetic_plan.rs`) are the120 model: build inputs in code, assert invariants, no fixtures/goldens.121 Env-gated tests (`ASSEMBLER_FIXTURES`, `ASSEMBLER_ASSEMBLIES`) skip silently122 when unset — fine for corpus tests, never for core logic.123- `cargo clippy --all-targets -- -D warnings` before committing Rust;124 `#[expect(lint, reason = "...")]` over blanket `#[allow]`.