Manual contentsArchitecture ConceptsBrowse 103 chapters
Manual 11 min read

Lock Policy

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::Mutex and std::sync::RwLock, which carry poison state. The async cousins in tokio::sync (Mutex, RwLock, Semaphore) do not poison - a panic while holding a tokio::sync::Mutex guard drops the guard cleanly and the next .lock().await succeeds. If your hot path is async and you don't need to acquire the guard from a sync context (a Drop impl, 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 std::collections::HashMap;
use std::sync::RwLock;
use suprnova::FrameworkError;

static FEATURE_FLAGS: RwLock<HashMap<String, bool>> = RwLock::new(HashMap::new());

pub fn enable(flag: &str) -> Result<(), FrameworkError> {
    let mut guard = FEATURE_FLAGS
        .write()
        .map_err(|_| FrameworkError::internal("feature flags lock poisoned"))?;
    guard.insert(flag.to_string(), true);
    Ok(())
}

pub fn is_enabled(flag: &str) -> Result<bool, FrameworkError> {
    let guard = FEATURE_FLAGS
        .read()
        .map_err(|_| FrameworkError::internal("feature flags lock poisoned"))?;
    Ok(guard.get(flag).copied().unwrap_or(false))
}

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 std::collections::HashMap;
use std::sync::RwLock;

static ALLOWED_INCLUDES: RwLock<HashMap<&'static str, Vec<&'static str>>> =
    RwLock::new(HashMap::new());

pub fn allows(dto: &str, field: &str) -> bool {
    ALLOWED_INCLUDES
        .read()
        .unwrap_or_else(|e| e.into_inner())
        .get(dto)
        .map(|fields| fields.contains(&field))
        .unwrap_or(false)
}

pub fn register(dto: &'static str, fields: &'static [&'static str]) {
    let mut guard = ALLOWED_INCLUDES
        .write()
        .unwrap_or_else(|e| e.into_inner());
    guard.insert(dto, fields.to_vec());
}

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 to Result would 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:

  1. Is the caller's signature Result<_, E>? If no, you have to recover in place - adding Result to a bool is usually a project-wide refactor and not worth it for a poison edge.
  2. 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 false to "is this name registered?" and falling back to the primary pool - that's "no, recover in place."
  3. 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 std::sync::atomic::{AtomicBool, Ordering};
use std::sync::RwLock;
use std::collections::HashMap;

static REGISTRY: RwLock<HashMap<String, ()>> = RwLock::new(HashMap::new());
static POISON_WARNED: AtomicBool = AtomicBool::new(false);

pub fn has(name: &str) -> bool {
    match REGISTRY.read() {
        Ok(g) => g.contains_key(name),
        Err(_) => {
            // Race-safe: only the first observer logs.
            if !POISON_WARNED.swap(true, Ordering::SeqCst) {
                tracing::warn!(
                    target: "myapp::registry",
                    "registry lock poisoned - `has({name})` degrading to false",
                );
            }
            false
        }
    }
}

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 to FrameworkError::internal on the writes and Result-returning reads; has degrades to false with the warn-once gate.
  • The notifications dispatcher and factory registry, mailable registry, mail transport, mail memory capture, and DB event listeners all return FrameworkError::internal on poison.
  • The data::registry include allowlist, auth::manager provider 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::internal becomes 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 BackendErrorPolicy story for backends that can be unreachable rather than poisoned; same explicit-choice principle, different failure mode.
  • Testing - how TestContainer::fake and 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.