Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Design constraints, invariants, and reference commands for the Rivet monorepo. F
**Always use versioned BARE (`vbare`) instead of raw `serde_bare` for any persisted or wire-format encoding unless explicitly told otherwise.** Raw `serde_bare::to_vec` / `from_slice` has no version header, so any future schema change forces hand-rolled `LegacyXxx` fallback structs. `vbare::OwnedVersionedData` plus a versioned `*.bare` schema is the standard pattern. Acceptable raw-bare exceptions: ephemeral in-memory encodings that never cross a process boundary or hit disk, and wire formats whose protocol version is coordinated out-of-band (e.g. an HTTP path like `/v{PROTOCOL_VERSION}/...` or another channel that pins both peers to one schema per call).

- Avoid raw `f64` fields in vbare protocol schemas that use hashable maps; generated Rust derives `Eq`/`Hash`, so encode floats as fixed bytes or an ordered wrapper.
- Version converters must manually map fields between versions; never convert by serializing one version and deserializing it as another.

When talking about "Rivet Actors" make sure to capitalize "Rivet Actor" as a proper noun and lowercase "actor" as a generic noun.

Expand Down Expand Up @@ -222,9 +223,10 @@ When the user asks to track something in a note, store it in `.agent/notes/` by

## Memory Leaks

- Never call `Box::leak` inside a per-request, per-error, or per-call code path. If the leak is for a `'static` reference required by an upstream API (e.g. `RivetErrorSchema`), intern the leaked value through a process-global `LazyLock<scc::HashMap<Key, &'static T>>` keyed on its identity so each unique value is leaked at most once. Examples: `BRIDGE_RIVET_ERROR_SCHEMAS` in `rivetkit-typescript/packages/rivetkit-napi/src/actor_factory.rs`.
- If every field in a leaked struct is a compile-time constant, use a `static`/`const` instead of `Box::leak(Box::new(...))`.
- `std::mem::forget` is only acceptable when an FFI handle cannot be dropped in the current context (e.g. napi `Ref::unref` requires an `Env`). Document the constraint inline and ensure the leak is bounded per actor/connection lifetime, not per call. Prefer routing the drop through an Env-bearing thread when possible.
- Do not introduce intentional leaks (`Box::leak`, `std::mem::forget`, `*_into_raw` without matching cleanup) unless an upstream API makes ownership impossible to express safely.
- Never call `Box::leak` inside a per-request, per-error, or per-call code path; if a `'static` reference is required, use a compile-time `static`/`const` or intern it through a process-global map keyed by identity.
- Interned leaks must be bounded by unique schema/config identity and must not include unbounded user input such as raw error messages, SQL, actor keys, request paths, or headers.
- `std::mem::forget` is only acceptable when an FFI handle cannot be dropped in the current context; document the constraint inline, prove the leak is bounded, and prefer routing cleanup through an Env-bearing owner.
- Spawned futures that capture JS callbacks or other heavy resources must have a guaranteed completion path (e.g. a `CancellationToken` whose clones are guaranteed to drop). A `spawn_local(async move { token.cancelled().await; ... })` only drains if every clone of the token is dropped or cancelled.

## Async Rust Locks
Expand Down
7 changes: 4 additions & 3 deletions engine/packages/api-builder/src/error_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ impl IntoResponse for ApiError {
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse::from(&RivetError {
schema: &rivet_error::INTERNAL_ERROR,
kind: rivet_error::RivetErrorKind::Static(&rivet_error::INTERNAL_ERROR),
meta: None,
message: None,
actor: None,
}),
)
};
Expand Down Expand Up @@ -84,8 +85,8 @@ pub struct ErrorResponse {
impl From<&RivetError> for ErrorResponse {
fn from(value: &RivetError) -> Self {
ErrorResponse {
group: value.group().into(),
code: value.code().into(),
group: value.group().to_owned().into(),
code: value.code().to_owned().into(),
message: value.message().into(),
metadata: value.metadata(),
}
Expand Down
12 changes: 8 additions & 4 deletions engine/packages/error-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,10 @@ fn derive_struct_error(input: DeriveInput, data_struct: &syn::DataStruct) -> Tok
.and_then(|v| ::serde_json::value::to_raw_value(&v).ok());

let error = RivetError {
schema: &SCHEMA,
kind: rivet_error::RivetErrorKind::Static(&SCHEMA),
meta: meta_json,
message: None,
actor: None,
};
::anyhow::Error::new(error)
}
Expand Down Expand Up @@ -193,9 +194,10 @@ fn derive_struct_error(input: DeriveInput, data_struct: &syn::DataStruct) -> Tok
let meta_json = ::serde_json::value::to_raw_value(&meta_value).ok();

let error = RivetError {
schema: &SCHEMA,
kind: rivet_error::RivetErrorKind::Static(&SCHEMA),
meta: meta_json,
message: None,
actor: None,
};
::anyhow::Error::new(error)
}
Expand Down Expand Up @@ -365,9 +367,10 @@ fn derive_enum_error(input: DeriveInput, data_enum: &syn::DataEnum) -> TokenStre
let meta_json = ::serde_json::value::to_raw_value(&meta_value).ok();

let error = RivetError {
schema: &SCHEMA,
kind: rivet_error::RivetErrorKind::Static(&SCHEMA),
meta: meta_json,
message: None,
actor: None,
};
::anyhow::Error::new(error)
}
Expand Down Expand Up @@ -454,9 +457,10 @@ fn derive_enum_error(input: DeriveInput, data_enum: &syn::DataEnum) -> TokenStre
let meta_json = ::serde_json::value::to_raw_value(&meta_value).ok();

let error = RivetError {
schema: &SCHEMA,
kind: rivet_error::RivetErrorKind::Static(&SCHEMA),
meta: meta_json,
message: None,
actor: None,
};
::anyhow::Error::new(error)
}
Expand Down
137 changes: 122 additions & 15 deletions engine/packages/error/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::INTERNAL_ERROR;
use crate::schema::RivetErrorSchema;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::{fmt, sync::OnceLock};

static EXPOSE_INTERNAL_ERRORS: OnceLock<bool> = OnceLock::new();
Expand All @@ -10,20 +10,111 @@
.get_or_init(|| matches!(std::env::var("RIVET_EXPOSE_ERRORS").as_deref(), Ok("1")))
}

/// Identifies the actor that was handling work when an error was produced.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActorSpecifier {
pub actor_id: String,
pub generation: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}

impl ActorSpecifier {
pub fn new(actor_id: impl Into<String>, generation: u64) -> Self {
Self {
actor_id: actor_id.into(),
generation,
key: None,
}
}

pub fn with_key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
}

impl fmt::Display for ActorSpecifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.key {
Some(key) => write!(
f,
"actor {} generation {} key {}",
self.actor_id, self.generation, key
),
None => write!(f, "actor {} generation {}", self.actor_id, self.generation),
}
}
}

impl std::error::Error for ActorSpecifier {}

#[derive(Debug, Clone)]
pub enum RivetErrorKind {
Static(&'static RivetErrorSchema),
Dynamic {
group: String,
code: String,
default_message: String,
},
}

impl RivetErrorKind {
pub fn group(&self) -> &str {
match self {
Self::Static(schema) => schema.group,
Self::Dynamic { group, .. } => group,
}
}

pub fn code(&self) -> &str {
match self {
Self::Static(schema) => schema.code,
Self::Dynamic { code, .. } => code,
}
}

pub fn default_message(&self) -> &str {
match self {
Self::Static(schema) => schema.default_message,
Self::Dynamic {
default_message, ..
} => default_message,
}
}

pub fn schema(&self) -> Option<&'static RivetErrorSchema> {
match self {
Self::Static(schema) => Some(schema),
Self::Dynamic { .. } => None,
}
}
}

#[derive(Debug, Clone)]
pub struct RivetError {
pub schema: &'static RivetErrorSchema,
pub kind: RivetErrorKind,
pub meta: Option<Box<serde_json::value::RawValue>>,
pub message: Option<String>,
pub actor: Option<ActorSpecifier>,
}

impl RivetError {
pub fn extract(error: &anyhow::Error) -> Self {
error
// `anyhow::Error::downcast_ref` walks both the chain and any
// `.context(...)` wrappers, so this finds an `ActorSpecifier` no matter
// where it was attached.
let actor = error.downcast_ref::<ActorSpecifier>().cloned();
let mut extracted = error
.chain()
.find_map(|x| x.downcast_ref::<Self>())
.cloned()
.unwrap_or_else(|| INTERNAL_ERROR.build_internal(error))
.unwrap_or_else(|| INTERNAL_ERROR.build_internal(error));
if extracted.actor.is_none() {
extracted.actor = actor;
}
extracted
}

pub(crate) fn build_internal(error: &anyhow::Error) -> Self {
Expand All @@ -34,31 +125,45 @@
let meta = serde_json::value::to_raw_value(&meta_json).ok();

Self {
schema: &INTERNAL_ERROR,
kind: RivetErrorKind::Static(&INTERNAL_ERROR),
meta,
message: expose_internal_errors().then(|| format!("Internal error: {}", error)),
actor: None,
}
}

pub fn group(&self) -> &'static str {
self.schema.group
pub fn group(&self) -> &str {
self.kind.group()
}

pub fn code(&self) -> &'static str {
self.schema.code
pub fn code(&self) -> &str {
self.kind.code()
}

pub fn message(&self) -> &str {
self.message
.as_deref()
.unwrap_or(self.schema.default_message)
.unwrap_or_else(|| self.kind.default_message())
}

pub fn metadata(&self) -> Option<serde_json::Value> {
self.meta
.as_ref()
.and_then(|raw| serde_json::from_str(raw.get()).ok())
}

pub fn actor(&self) -> Option<&ActorSpecifier> {
self.actor.as_ref()
}

pub fn with_actor(mut self, actor: ActorSpecifier) -> Self {
self.actor = Some(actor);
self
}

pub fn schema(&self) -> Option<&'static RivetErrorSchema> {
self.kind.schema()
}
}

impl fmt::Display for RivetError {
Expand All @@ -73,14 +178,12 @@
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{

Check warning on line 181 in engine/packages/error/src/error.rs

View workflow job for this annotation

GitHub Actions / Rustfmt

Diff in /home/runner/work/rivet/rivet/engine/packages/error/src/error.rs
use serde::ser::SerializeStruct;

let mut state = if self.meta.is_some() {
serializer.serialize_struct("RivetError", 4)?
} else {
serializer.serialize_struct("RivetError", 3)?
};
let field_count =
3 + usize::from(self.meta.is_some()) + usize::from(self.actor.is_some());
let mut state = serializer.serialize_struct("RivetError", field_count)?;

state.serialize_field("group", self.group())?;
state.serialize_field("code", self.code())?;
Expand All @@ -90,6 +193,10 @@
state.serialize_field("meta", meta)?;
}

if let Some(actor) = &self.actor {
state.serialize_field("actor", actor)?;
}

state.end()
}
}
2 changes: 1 addition & 1 deletion engine/packages/error/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
mod error;
mod schema;

pub use error::RivetError;
pub use error::{ActorSpecifier, RivetError, RivetErrorKind};
pub use schema::{MacroMarker, RivetErrorSchema, RivetErrorSchemaWithMeta};

pub use rivet_error_macros::RivetError;
Expand Down
11 changes: 7 additions & 4 deletions engine/packages/error/src/schema.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::error::RivetError;
use crate::error::{RivetError, RivetErrorKind};
use serde::Serialize;
use std::marker::PhantomData;

Expand Down Expand Up @@ -48,9 +48,10 @@ impl RivetErrorSchema {
/// Builds an anyhow::Error from this schema
pub fn build(&'static self) -> anyhow::Error {
let error = RivetError {
schema: self,
kind: RivetErrorKind::Static(self),
meta: None,
message: None,
actor: None,
};
anyhow::Error::new(error)
}
Expand All @@ -67,9 +68,10 @@ impl<T: Serialize> RivetErrorSchemaWithMeta<T> {
let meta_json = serde_json::value::to_raw_value(&meta).ok();

let error = RivetError {
schema: &self.schema,
kind: RivetErrorKind::Static(&self.schema),
meta: meta_json,
message: Some(message),
actor: None,
};
anyhow::Error::new(error)
}
Expand All @@ -78,9 +80,10 @@ impl<T: Serialize> RivetErrorSchemaWithMeta<T> {
impl From<&'static RivetErrorSchema> for RivetError {
fn from(value: &'static RivetErrorSchema) -> Self {
RivetError {
schema: value,
kind: RivetErrorKind::Static(value),
meta: None,
message: None,
actor: None,
}
}
}
Loading
Loading