Suprnova is a single long-lived Tokio process, not a fleet of short-lived
PHP workers. Every process-global registry, singleton, and shared cache
you bind at boot outlives every request that touches it. That changes one
small but consequential thing about how you reach for std::sync::Mutex
and std::sync::RwLock: a panic while holding a guard poisons the lock
for the rest of the process's life, and the next caller has to decide
what to do about it. This chapter is the project-wide policy for that
decision - two sanctioned patterns, when to pick which, and why you
should never reach for a raw .lock().unwrap() in framework or
application code.
Why this chapter exists
In Laravel you never thought about poisoned locks because there were
none. PHP is shared-nothing: a fatal error tears down one request's
process, the next request starts in a fresh one, no in-memory state
survives to corrupt. Suprnova runs the opposite way. The process boots
once, registries get populated, and they stay alive for the entire
lifetime of the binary. A handler that panics while holding a write
guard on a process-global RwLock leaves that lock poisoned - every
subsequent .read() and .write() returns Err(PoisonError) forever,
unless someone explicitly recovers it.
The default Rust idiom - .lock().unwrap() - converts that Err into a
panic. Which then becomes another poisoned lock somewhere up the stack.
Which then takes down the next subsystem that touches it. One bad request
cascades into a half-dead process.
The policy below prevents that cascade.
Scope. This applies to
std::sync::Mutexandstd::sync::RwLock, which carry poison state. The async cousins intokio::sync(Mutex,RwLock,Semaphore) do not poison - a panic while holding atokio::sync::Mutexguard drops the guard cleanly and the next.lock().awaitsucceeds. If your hot path is async and you don't need to acquire the guard from a sync context (aDropimpl, a framework callback, a CLI subcommand), prefer the Tokio variants and the question goes away.
The two sanctioned patterns
Every place in the framework that holds a std::sync lock uses one of
exactly two patterns. Pick the same way in your own code.
Pattern 1 - Map poison to a returned error
When the caller already returns Result<_, E> and one more ? doesn't
change its shape, surface the poison as an error and let the request
fail cleanly. The framework uses internal pub(crate) helpers
(lock::read, lock::write, lock::lock) that map a poisoned guard
to FrameworkError::internal("<context> lock poisoned"), embedding a
caller-supplied label so logs can tell which subsystem poisoned without
every call site wrapping the error itself.
The pattern those helpers encode is short enough to write inline in your application code:
use HashMap;
use RwLock;
use FrameworkError;
static FEATURE_FLAGS: = new;
Inside a handler, is_enabled(...)? collapses through the same
FrameworkError → HttpResponse path every other framework error uses:
the client gets a sanitised 500 with {"message": "Internal Server Error"}, the structured log captures the labelled poison message, the
request id is preserved end-to-end, and the rest of the process keeps
serving traffic. See the Errors chapter for the full
conversion path.
Use this pattern when:
- The caller already returns
Result(most fallible operations do). - A poisoned lock represents a real, unrecoverable failure of the subsystem - there is no sane "partial truth" to fall back to.
- You want operators to see the poison in logs the next time the subsystem is touched. The labelled message is your forensic crumb.
The framework's notifications dispatcher, mail transport, mailable registry, db event listeners, and named connection registry all use this pattern. A panic in any one of them surfaces as a 500 on the next request that hits the registry; everything else keeps running.
Pattern 2 - Recover in place with into_inner()
When the caller's signature is not fallible (a bool lookup, a hot
routing check, a path the request lifecycle relies on) or when the
shared state is structurally safe to use after a partial write,
recover the guard and continue:
use HashMap;
use RwLock;
static ALLOWED_INCLUDES: =
new;
PoisonError::into_inner() returns the guard despite the poison.
Subsequent reads and writes proceed normally - the lock stays poisoned
for is_poisoned() queries, but data flow is restored.
The framework uses this pattern in data::registry (the include-set
allowlist read on every JSON:API response), auth::manager (the named
auth-provider map), app::paths (the resolved-paths cache), the
testing fakes for mail and events, and the loaded-env-keys map in
config. Every one is a place where either no caller has a Result to
return, or the state is append-only and structurally safe to keep using.
Use this pattern when:
- The caller's signature is plain (
bool,&str, a clone of a stored value) and changing it toResultwould force every caller - sometimes every framework subsystem - to bubble. - The shared state can tolerate a partial write. Append-only maps and caches are the typical shape: the worst case is a missing or stale entry, which the caller already handles (default-deny, fall back to primary, recompute).
- The hot path runs often enough that returning an error on every subsequent request would be operationally worse than degrading.
How to choose between them
The decision rule, in one sentence: if the worst case of using post-poison state is a wrong answer with consequences, map to an error; if it's a missing or stale entry the caller already handles, recover in place.
Walk it through:
- Is the caller's signature
Result<_, E>? If no, you have to recover in place - addingResultto aboolis usually a project-wide refactor and not worth it for a poison edge. - If a half-written value were observed, would the application
make a wrong decision with real-world consequences? Charging a
wrong customer, allowing an unauthorised include, granting access
to the wrong tenant - that's "yes, map to an error." Returning
falseto "is this name registered?" and falling back to the primary pool - that's "no, recover in place." - Is the state append-only or naturally idempotent on re-registration? If yes, recover-in-place is safe. If a write is a state-machine transition that depends on the prior value, prefer map-to-error so you don't compound a corruption.
When in doubt, map to an error. A request returning 500 is a loud signal you can fix; silent wrong answers are not.
Never reach for .lock().unwrap()
The forbidden shape:
// NEVER - one panic anywhere in the call graph below
// this line poisons the lock and every subsequent caller
// turns the poison into another panic.
let mut guard = SOMETHING.lock.unwrap;
.expect("…") is the same thing with a nicer message. Both convert a
poisoned-lock Err into a panic that the request-lifecycle's
AssertUnwindSafe(...).catch_unwind() net catches and converts to a
500 - that net is a last line of defence, not licence to skip the
decision above. Public framework APIs and application code must pick
one of the two sanctioned patterns.
The two exceptions where .unwrap() is acceptable on a std::sync
lock:
- Test setup that wants to assert poisoning was reached -
framework/src/lock.rs's own poison-induction helper uses.unwrap()inside the panicking thread on purpose. - The error path of a poisoning operation that already failed - by
the time you're inside
poison_rw(...)'s thread, the panic is the point.
If you're not in one of those, pick a pattern from the section above.
What if my function returns bool?
This is the situation ConnectionRegistry::has lives in. It's a
bool lookup on the hot path of the executor's read-replica routing,
called inline as if ConnectionRegistry::has("read_replica").await { … }.
Widening it to Result<bool, FrameworkError> would force every caller
in the executor to ?-bubble, propagating an internal-error code path
into routing decisions that just want a yes/no.
The recover-in-place pattern handles this - return false and let the
caller's fallback logic kick in (here, the executor drops back to the
primary pool, which is the safe behaviour anyway). To make sure
operators still see the condition, emit a one-shot tracing::warn! the
first time poison is observed:
use ;
use RwLock;
use HashMap;
static REGISTRY: = new;
static POISON_WARNED: AtomicBool = new;
The swap-based gate matters: RwLock poison is sticky, so without
the gate every subsequent call would re-fire the warning and flood your
logs. With the gate, you get exactly one warn per process per registry,
and a corresponding Result-returning getter (get, register) on
the same registry will surface the poison the next time anything
actually needs the lookup to succeed. That gives operators both
signals: an early "something is wrong" warn, and a hard 500 the moment
a request truly depended on the registry.
What the framework already protects
You don't have to apply this policy to any state the framework owns - it's already in place. Concretely:
- The named connection registry (
ConnectionRegistry::register,get,has) maps poison toFrameworkError::internalon the writes andResult-returning reads;hasdegrades tofalsewith the warn-once gate. - The notifications dispatcher and factory registry, mailable registry,
mail transport, mail memory capture, and DB event listeners all
return
FrameworkError::internalon poison. - The
data::registryinclude allowlist,auth::managerprovider map,app::paths, the loaded-env-keys cache, and the in-memory testing fakes all recover in place.
Where you intersect those subsystems through their public API
(Notification::send, Mail::send, Auth::user, DB::connection,
the JSON:API response path), a poisoned framework lock surfaces as a
clean 500 - never a panic at your call site.
Why Suprnova diverges
Laravel doesn't have a lock policy because it doesn't have long-lived shared state. Each PHP request gets its own process, its own memory, its own copies of every singleton. There's no in-memory registry to poison and no concept of "the next request" inheriting damage from the previous one - the runtime guarantees a clean slate.
Suprnova is built on Tokio, which gives you exactly the long-lived shared state that PHP rules out. Cheap WebSockets, in-memory caches, connection pools you don't pay to rebuild - all of these need process-global registries that outlive any single request. That capability is the whole point of moving to Rust for this style of app (see the introduction for the framework's full motivation). The cost of having it is that you now have to think about what happens when a panicked thread leaves shared state in a guarded condition, because there is shared state to leave.
The two-pattern policy is the smallest answer that keeps the capability and removes the cost. Recover in place where the state is safe to keep using; map to an error where you'd rather have a clean 500 than a wrong answer. Both options leave the rest of the process serving traffic. Neither leaves a panicked unwrap waiting to take down the subsystem above.
This is the same shape as the fail-open vs fail-closed decision the framework applies to unreachable cache and rate-limit backends: an explicit policy choice at the call site, not a default. Async-everywhere gives you long-lived state; the framework gives you the playbook for keeping it honest.
Next
- Errors - how
FrameworkError::internalbecomes the sanitised 500 the client receives, with the labelled poison message preserved in your structured log. - Container - where the process-global registries this policy protects actually live, and why task-local/thread-local scoping keeps tests from inheriting each other's bindings.
- Lifecycle - the panic boundary
(
execute_chain_safely) that catches the last-resort unwrap and converts it to a 500, so you understand exactly what the safety net does and why it isn't an excuse to skip the policy above. - Rate Limiting - the parallel
BackendErrorPolicystory for backends that can be unreachable rather than poisoned; same explicit-choice principle, different failure mode. - Testing - how
TestContainer::fakeand the thread-local container layer keep parallel tests from polluting each other's registries, which is the test-time complement to the poison-handling story.
