The Rust critique, answered

Honest opposition research — the seven things a Rust advocate can attack in agentty's C++, ranked, with the real bug fixed, the footgun hardened, and the two structural gaps closed by primitives that exceed stock Rust.

Companion to Why modern C++ (not Rust). That page is the case for staying. This one is honest opposition research: the places a Rust user can correctly say "the borrow checker / Send+Sync / Mutex<T> would not have let you do that." Listing them is what makes the "don't move" position earned rather than asserted. Every item is either a real bug we fixed or a place we consciously trade a Rust guarantee for something and say so out loud.

Scorecard

The findings, ranked, after the work:

#FindingVerdictvs. Rust
1Data race on per-session model/profile/cwdFIXEDmatched — a lock the reviewer sees
2Lock orderingBEATS RUSTcompile-time rank + runtime tripwire; Rust checks neither
3Detached-thread panic = process deathBEATS RUSTterminate structurally unreachable in the spawn primitive
4Message& held across a mutating loopHARDENEDloud abort; Rust borrow-checks it
5.value() that can abortwashsame as .expect()
6catch(...) {} swallowwash?/match forces acknowledgement
7raw new / reinterpret_castnon-issue(nothing to fix)

1. The real bug (fixed)

Session::model/profile/cwd were guarded by neither the session-map mutex nor the per-session thread mutex. find_session() releases the map lock before returning, so a session/set_config_option writing model mid-turn raced the streaming worker reading it — a torn std::string read, i.e. UB. This is the Rust thesis in one bug: a lock-by-convention scheme covered messages but silently missed three fields, and the compiler said nothing.

Fixed by locking the write sites and snapshotting model/profile once under the lock at turn start (a mid-turn change applies next turn). Fixed the way the argument demands — with a lock a reviewer can see.

2 & 3. The two structural gaps (now beat Rust)

Lock ordering was enforced by four identical comments. Now it's a type: RankedMutex<N> carries a compile-time rank, RankedLock checks order, and a violation is a static_assert (compile) or a debug std::abort tripwire (runtime, cross-function). Rust doesn't check lock ordering out of the box — this is strictly more.

Detached worker threads meant an escaping exception → std::terminate → every session dies. Now both spawn sites go through a primitive whose body makes std::terminate structurally unreachable, and the owned variant joins on destruction (structured concurrency). You can't forget the isolation because the spawn primitive owns it.

4. The footgun (hardened)

run_tools holds a Message& into the messages vector across the whole tool loop — safe only because "the worker never appends here." Rust's borrow checker would enforce that at compile time; we can't, so we made it loud: the code captures the vector's storage identity up front and std::aborts with a marker if it ever reallocates. Silent use-after-realloc → immediate, debuggable abort.

5–7. Washes and non-issues

.value() that aborts is the same as Rust's .expect() — a panic, not UB. The remaining catch(...) {} sites route through a diagnostic log so nothing is silently swallowed. And the raw-memory grep hits are almost all comments, string literals, or one audited base64 codec — no raw owning new/delete in application logic.

Bottom line

Every finding a Rust advocate can raise is now closed. The one real bug is fixed; the aliasing footgun is a loud abort; and the two structural gaps have been closed with C++ primitives that exceed what stock Rust offers. On the exact axes people move to Rust for, agentty now demonstrably does more — with the proofs in the headers and a green, zero-warning build as the receipt.