Would Rust have prevented these bugs?
Readers of a survey of C++ memory and threading bugs ask this, so each lesson records an answer: would writing this code in safe Rust have prevented the bug? Each answer is a one-line judgment made by reading the fix, not by porting the code, and the framing assumes the native pure-Rust Cap'n Proto implementation with RPC (capnproto-rust), so there is no FFI layer to hide behind. The counts:
| Records | Yes | Partly | No | Not applicable |
|---|---|---|---|---|
| All 102 records | 29 | 28 | 38 | 7 |
| The 82 defects in production code | 24 | 27 | 29 | 2 |
Over production defects the three verdicts are close to a third each. That is the honest summary: Rust would have rejected about a third of these bugs at compile time, changed the shape of another third without removing the mistake, and had nothing to say about the rest.
What the verdicts mean
- Yes: the compiler rejects the defective code as written. A closure capturing a
local by reference and stored somewhere longer-lived; a plain field read on one thread
and written on another; a struct that borrows a temporary; a hook reached from a base
constructor. These are the borrow checker and the
Send/Synctraits doing what they are for. - Partly: Rust makes the bug harder to write or changes what it does, but the logic
error remains. An
Optionthat panics onunwrap()where anAssertaborted; aResultthe author can stillunwrap(); anArcthat keeps an object alive instead of freeing it, so a crash becomes a hang. - No: a logic, protocol, lifecycle, deadlock, test or CI bug with nothing for the compiler to see. Shutdown order, a notify on the wrong path, a timeout too short.
- Not applicable: a toolchain or build problem in which no libmultiprocess or Bitcoin Core code is wrong.
Three things the counts hide
Send and Sync catch data races, not ordering. Every field in the disconnect
path that was written under a lock on one thread and read bare on another is a compile
error in Rust, because the field would have to live inside a Mutex for the type to be
Sync. That is the bulk of the "yes" verdicts in the locks group. But
Condvar::notify_all takes no guard, so the unlocked notify that lost a wakeup
compiles; holding one mutex while blocking on a hand-off to the loop thread is a
lock-order deadlock in either language; and a "done" signal sent for a partial
condition is a protocol error the type system cannot express. Rust removes the races
ThreadSanitizer found and leaves the hangs gdb found.
capnproto-rust's RPC objects are single-threaded. Its clients, connections and call
contexts are Rc-based and !Send, so a worker thread cannot hold a proxy, destroy one,
or call getResults() on a call context. The whole
wrong-thread group is ruled out by construction, and so is
the two-destroyer layout behind several lifetime bugs: nothing but the loop thread can
touch those objects. The cost is a different model for blocking calls. libmultiprocess
runs method bodies on worker threads that block on locks and on other IPC calls; in
capnproto-rust, blocking work has to be handed to another thread explicitly and its
result sent back. Several of the fixes ended up in that shape anyway (results built on
the loop thread, parameters copied before the hand-off), and the hand-off itself has
the same cancel-versus-join gap that the open shutdown work is closing.
The idiomatic Arc fix extends a lifetime instead of freeing it. Where C++ had a
raw pointer to a connection or to node state, Rust would have an Arc or a Weak. The
Arc compiles and removes the use-after-free, and in most programs that is the end of
it. In this code the object kept alive is often a connection or an EventLoopRef, and
the event loop exits when its last reference drops, so a lifetime that extends past
shutdown is a node that never exits. The ledger already contains that hang, twice, from
C++ code that kept references too long. The "yes" verdicts in the
outlives-referent group therefore come with a caveat
their notes state: the crash goes away, and the ownership design still has to be right
or the failure moves to shutdown.
By pattern group
| Group | Bugs | Yes | Partly | No | n/a |
|---|---|---|---|---|---|
| Work or cleanup on the wrong thread | 8 | 5 | 2 | 1 | 0 |
| An object outliving what it points to | 12 | 9 | 2 | 1 | 0 |
| Built or torn down in the wrong order | 5 | 4 | 0 | 1 | 0 |
| Serving before ready, freeing before done | 8 | 2 | 3 | 3 | 0 |
| Locks, waits and wakeups | 11 | 4 | 2 | 5 | 0 |
| A contract changed and a caller was left behind | 6 | 2 | 2 | 2 | 0 |
| An in-process interface trusting what now comes from another process | 14 | 2 | 6 | 6 | 0 |
| Error paths that skip cleanup or completion | 16 | 0 | 10 | 6 | 0 |
| Process spawning, file descriptors and signals | 6 | 1 | 1 | 4 | 0 |
| Test timing, and tests that cannot fail | 6 | 0 | 0 | 6 | 0 |
| CI, toolchain and platform | 10 | 0 | 0 | 3 | 7 |
The split follows the mechanism. Lifetime and thread-ownership groups are mostly "yes"; the groups about what a peer sends, what an error path does and when a process is ready are mostly "partly" or "no", because those are decisions rather than memory.
Examples
Each note is the one sentence recorded with the lesson.
Yes
- Callable stored as a reference to a temporary: a struct that borrows a temporary created in the same return statement cannot be returned; the borrow checker rejects it, so the callable would be stored by value from the start.
- Disconnect callback reads fields the loop writes, without the lock:
fields read on a worker thread and written on the event-loop thread would have to be
inside a
Mutex(or be atomics) for the types to beSync, so each of the three TSan races is a compile error; which lock, and the rule that the destructor must wait forremoveSyncCleanup, remain design decisions. - Capability reference count touched off the loop thread:
a non-atomic reference count is
Rc, which is neitherSendnorSync, so copying or dropping a capability on the test thread while the event loop owns it is a compile error; capnproto-rust clients areRc-based for exactly this reason. - Downcast from a base constructor and destructor: Rust has no base-class constructors or destructors that run on a partially built object; a struct is fully initialized before any method can see it, so the downcast-from-base hook cannot be written.
- ProxyServer destroyed after its Connection:
a
ProxyServercould not keep a plain reference to aConnectionthe RPC system frees first: it would need anArc(keeping the connection alive past its teardown) or aWeakwhoseNoneit must handle; and with capnproto-rust'sRc-based server objects a worker thread could not keep the server alive past the loop thread's teardown in the first place, at the cost of a different model for blocking method bodies.
Partly
- Negative or NaN timeout hits an
Assume:std::time::Durationis unsigned, so a negative internal timeout is unrepresentable, but the wire value is still anf64andDuration::from_secs_f64panics on negative or NaN input, so the abort remains unless the conversion is written withtry_from_secs_f64. - Worker thread and disconnect handler both destroy the same thread proxy:
the deadlock itself, holding
Waiter::m_mutexwhile blocking onsync(), is a lock-ordering bug Rust does not prevent, but the interleaving that produces it, a worker thread destroying aProxyClient<Thread>that the event loop also tears down, is ruled out by capnproto-rust's single-threaded (Rc,!Send) capabilities, which is what moving every erase onto the loop thread achieved by convention; the trade-off is a different threading model for blocking calls. - Callback runs in the child between fork and exec:
std::process::Commanddoes what the fix does (argv built before fork, only async-signal-safe calls in the child), so the bug is avoided by using the standard library; a hand-rolledlibc::fork()is unsafe FFI where the compiler cannot see the allocation in the child. - Proxy destructor throws when the peer is gone:
the remote
destroywould return aResultthe compiler makes theDropimpl acknowledge, butDropcannot propagate it and a panic there during unwinding aborts just asterminatedoes, so the choice to log and continue is still the author's. - Example holds the event loop through a raw pointer:
mainwould hold anArc<EventLoop>so the loop thread could not free the object under it, but the client count that makesloop()return is application logic, so the same gap turns the use-after-free into async()posted to a loop that has already returned, a hang.
No
- WalletLoader destructor stops the scheduler on the loop thread: a deadlock between a destructor on the loop thread and a scheduler thread waiting for that loop is an ordering bug; capnproto-rust also drops server objects on its loop thread, and the scheduler thread's call would still have to be handed to that loop and awaited, so the same two-thread cycle compiles.
- Ctrl-C does not wake
waitTipChanged: a condition variable whose notify lives on one of two shutdown paths is a missed-wakeup logic bug; Rust'sCondvarhas the same split between the flag and the notification. - Callback registry keeps event loop references alive:
when a disconnected slot's closure is dropped is a policy of the signal registry; a
Vecof boxed closures that defers cleanup until the next connect compiles fine in Rust and holds itsEventLoopRefjust as long. - Lost wakeup in the simultaneous-calls test:
Condvar::notify_alltakes no guard, so the same unlocked notify compiles and loses the wakeup the same way; capnproto-rust's single-threaded model would have the test await the responses on the loop instead of waking another thread, which sidesteps the bug rather than catching it. createNewBlockasserts on options from the wire: anassert!on a field received from the wire panics in Rust just asAssertaborts in C++; treating peer input as already validated is a contract mistake, not a memory one.
The seven "not applicable" records are the toolchain and build problems: a CI memory limit, a two-compiler ABI disagreement, a Cap'n Proto header under C++20, a misdetected feature macro, a mingw-w64 runtime bug. A capnproto-rust build would not contain kj at all, but that is a different program, not the same program in a different language, and the question this page asks is about the code as written.