use crate::types::{CancelPhase, CancelReason, Outcome};
use crate::util::det_hash::{BTreeMap, DetHashSet, DetHasher};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::hash::{Hash, Hasher};
use thiserror::Error;
pub const WASM_ABI_MAJOR_VERSION: u16 = 1;
pub const WASM_ABI_MINOR_VERSION: u16 = 0;
pub const WASM_ABI_SIGNATURE_FINGERPRINT_V1: u64 = 4_558_451_663_113_424_898;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmAbiVersion {
pub major: u16,
pub minor: u16,
}
impl WasmAbiVersion {
pub const CURRENT: Self = Self {
major: WASM_ABI_MAJOR_VERSION,
minor: WASM_ABI_MINOR_VERSION,
};
}
impl fmt::Display for WasmAbiVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum WasmAbiCompatibilityDecision {
Exact,
BackwardCompatible {
producer_minor: u16,
consumer_minor: u16,
},
MajorMismatch {
producer_major: u16,
consumer_major: u16,
},
ConsumerTooOld {
producer_minor: u16,
consumer_minor: u16,
},
}
impl WasmAbiCompatibilityDecision {
#[must_use]
pub const fn is_compatible(self) -> bool {
matches!(self, Self::Exact | Self::BackwardCompatible { .. })
}
#[must_use]
pub const fn decision_name(self) -> &'static str {
match self {
Self::Exact => "exact",
Self::BackwardCompatible { .. } => "backward_compatible",
Self::MajorMismatch { .. } => "major_mismatch",
Self::ConsumerTooOld { .. } => "consumer_too_old",
}
}
}
#[must_use]
pub const fn classify_wasm_abi_compatibility(
producer: WasmAbiVersion,
consumer: WasmAbiVersion,
) -> WasmAbiCompatibilityDecision {
if producer.major != consumer.major {
return WasmAbiCompatibilityDecision::MajorMismatch {
producer_major: producer.major,
consumer_major: consumer.major,
};
}
if consumer.minor < producer.minor {
return WasmAbiCompatibilityDecision::ConsumerTooOld {
producer_minor: producer.minor,
consumer_minor: consumer.minor,
};
}
if consumer.minor == producer.minor {
WasmAbiCompatibilityDecision::Exact
} else {
WasmAbiCompatibilityDecision::BackwardCompatible {
producer_minor: producer.minor,
consumer_minor: consumer.minor,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiChangeClass {
AdditiveField,
AdditiveSymbol,
BehavioralTightening,
BehavioralRelaxation,
SymbolRemoval,
ValueEncodingChange,
OutcomeSemanticChange,
CancellationSemanticChange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiVersionBump {
None,
Minor,
Major,
}
#[must_use]
pub const fn required_wasm_abi_bump(change: WasmAbiChangeClass) -> WasmAbiVersionBump {
match change {
WasmAbiChangeClass::AdditiveField
| WasmAbiChangeClass::AdditiveSymbol
| WasmAbiChangeClass::BehavioralRelaxation => WasmAbiVersionBump::Minor,
WasmAbiChangeClass::BehavioralTightening
| WasmAbiChangeClass::SymbolRemoval
| WasmAbiChangeClass::ValueEncodingChange
| WasmAbiChangeClass::OutcomeSemanticChange
| WasmAbiChangeClass::CancellationSemanticChange => WasmAbiVersionBump::Major,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiSymbol {
RuntimeCreate,
RuntimeClose,
ScopeEnter,
ScopeClose,
TaskSpawn,
TaskJoin,
TaskCancel,
FetchRequest,
}
impl WasmAbiSymbol {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RuntimeCreate => "runtime_create",
Self::RuntimeClose => "runtime_close",
Self::ScopeEnter => "scope_enter",
Self::ScopeClose => "scope_close",
Self::TaskSpawn => "task_spawn",
Self::TaskJoin => "task_join",
Self::TaskCancel => "task_cancel",
Self::FetchRequest => "fetch_request",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiPayloadShape {
Empty,
HandleRefV1,
ScopeEnterRequestV1,
SpawnRequestV1,
CancelRequestV1,
FetchRequestV1,
OutcomeEnvelopeV1,
}
impl WasmAbiPayloadShape {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Empty => "empty",
Self::HandleRefV1 => "handle_ref_v1",
Self::ScopeEnterRequestV1 => "scope_enter_request_v1",
Self::SpawnRequestV1 => "spawn_request_v1",
Self::CancelRequestV1 => "cancel_request_v1",
Self::FetchRequestV1 => "fetch_request_v1",
Self::OutcomeEnvelopeV1 => "outcome_envelope_v1",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmAbiSignature {
pub symbol: WasmAbiSymbol,
pub request: WasmAbiPayloadShape,
pub response: WasmAbiPayloadShape,
}
pub const WASM_ABI_SIGNATURES_V1: [WasmAbiSignature; 8] = [
WasmAbiSignature {
symbol: WasmAbiSymbol::RuntimeCreate,
request: WasmAbiPayloadShape::Empty,
response: WasmAbiPayloadShape::HandleRefV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::RuntimeClose,
request: WasmAbiPayloadShape::HandleRefV1,
response: WasmAbiPayloadShape::OutcomeEnvelopeV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::ScopeEnter,
request: WasmAbiPayloadShape::ScopeEnterRequestV1,
response: WasmAbiPayloadShape::HandleRefV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::ScopeClose,
request: WasmAbiPayloadShape::HandleRefV1,
response: WasmAbiPayloadShape::OutcomeEnvelopeV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::TaskSpawn,
request: WasmAbiPayloadShape::SpawnRequestV1,
response: WasmAbiPayloadShape::HandleRefV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::TaskJoin,
request: WasmAbiPayloadShape::HandleRefV1,
response: WasmAbiPayloadShape::OutcomeEnvelopeV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::TaskCancel,
request: WasmAbiPayloadShape::CancelRequestV1,
response: WasmAbiPayloadShape::OutcomeEnvelopeV1,
},
WasmAbiSignature {
symbol: WasmAbiSymbol::FetchRequest,
request: WasmAbiPayloadShape::FetchRequestV1,
response: WasmAbiPayloadShape::OutcomeEnvelopeV1,
},
];
#[must_use]
pub fn wasm_abi_signature_fingerprint(signatures: &[WasmAbiSignature]) -> u64 {
let mut hasher = DetHasher::default();
for signature in signatures {
signature.hash(&mut hasher);
}
hasher.finish()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmHandleRef {
pub kind: WasmHandleKind,
pub slot: u32,
pub generation: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmHandleKind {
Runtime,
Region,
Task,
CancelToken,
FetchRequest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum WasmAbiValue {
Unit,
Bool(bool),
I64(i64),
U64(u64),
String(String),
Bytes(Vec<u8>),
Handle(WasmHandleRef),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiErrorCode {
CapabilityDenied,
InvalidHandle,
DecodeFailure,
CompatibilityRejected,
InternalFailure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbiRecoverability {
Transient,
Permanent,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmAbiFailure {
pub code: WasmAbiErrorCode,
pub recoverability: WasmAbiRecoverability,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmAbiCancellation {
pub kind: String,
pub phase: String,
pub origin_region: String,
pub origin_task: Option<String>,
pub timestamp_nanos: u64,
pub message: Option<String>,
pub truncated: bool,
}
impl WasmAbiCancellation {
pub fn from_reason(reason: &CancelReason, phase: CancelPhase) -> Self {
Self {
kind: format!("{:?}", reason.kind()).to_lowercase(),
phase: format!("{phase:?}").to_lowercase(),
origin_region: reason.origin_region().to_string(),
origin_task: reason.origin_task().map(|task| task.to_string()),
timestamp_nanos: reason.timestamp().as_nanos(),
message: reason.message().map(std::string::ToString::to_string),
truncated: reason.any_truncated(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmAbortPropagationMode {
RuntimeToAbortSignal,
AbortSignalToRuntime,
Bidirectional,
}
impl WasmAbortPropagationMode {
#[must_use]
pub const fn propagates_runtime_to_abort_signal(self) -> bool {
matches!(self, Self::RuntimeToAbortSignal | Self::Bidirectional)
}
#[must_use]
pub const fn propagates_abort_signal_to_runtime(self) -> bool {
matches!(self, Self::AbortSignalToRuntime | Self::Bidirectional)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmAbortInteropSnapshot {
pub mode: WasmAbortPropagationMode,
pub boundary_state: WasmBoundaryState,
pub abort_signal_aborted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmAbortInteropUpdate {
pub next_boundary_state: WasmBoundaryState,
pub abort_signal_aborted: bool,
pub propagated_to_runtime: bool,
pub propagated_to_abort_signal: bool,
}
#[must_use]
pub const fn wasm_boundary_state_for_cancel_phase(phase: CancelPhase) -> WasmBoundaryState {
match phase {
CancelPhase::Requested | CancelPhase::Cancelling => WasmBoundaryState::Cancelling,
CancelPhase::Finalizing => WasmBoundaryState::Draining,
CancelPhase::Completed => WasmBoundaryState::Closed,
}
}
#[must_use]
pub fn apply_abort_signal_event(snapshot: WasmAbortInteropSnapshot) -> WasmAbortInteropUpdate {
let propagated_to_runtime = snapshot.mode.propagates_abort_signal_to_runtime()
&& !snapshot.abort_signal_aborted
&& matches!(
snapshot.boundary_state,
WasmBoundaryState::Bound | WasmBoundaryState::Active
);
let next_boundary_state = if propagated_to_runtime {
match snapshot.boundary_state {
WasmBoundaryState::Bound => WasmBoundaryState::Closed,
WasmBoundaryState::Active => WasmBoundaryState::Cancelling,
state => state,
}
} else {
snapshot.boundary_state
};
WasmAbortInteropUpdate {
next_boundary_state,
abort_signal_aborted: true,
propagated_to_runtime,
propagated_to_abort_signal: false,
}
}
#[must_use]
pub fn apply_runtime_cancel_phase_event(
snapshot: WasmAbortInteropSnapshot,
phase: CancelPhase,
) -> WasmAbortInteropUpdate {
let target_state = wasm_boundary_state_for_cancel_phase(phase);
let next_boundary_state = if snapshot.boundary_state == target_state
|| is_valid_wasm_boundary_transition(snapshot.boundary_state, target_state)
{
target_state
} else {
snapshot.boundary_state
};
let should_abort = snapshot.mode.propagates_runtime_to_abort_signal()
&& !snapshot.abort_signal_aborted
&& matches!(
phase,
CancelPhase::Requested
| CancelPhase::Cancelling
| CancelPhase::Finalizing
| CancelPhase::Completed
);
let abort_signal_aborted = snapshot.abort_signal_aborted
|| (snapshot.mode.propagates_runtime_to_abort_signal()
&& matches!(
phase,
CancelPhase::Requested
| CancelPhase::Cancelling
| CancelPhase::Finalizing
| CancelPhase::Completed
));
WasmAbortInteropUpdate {
next_boundary_state,
abort_signal_aborted,
propagated_to_runtime: false,
propagated_to_abort_signal: should_abort,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum WasmAbiOutcomeEnvelope {
Ok { value: WasmAbiValue },
Err { failure: WasmAbiFailure },
Cancelled { cancellation: WasmAbiCancellation },
Panicked { message: String },
}
impl WasmAbiOutcomeEnvelope {
#[must_use]
pub fn from_outcome(outcome: Outcome<WasmAbiValue, WasmAbiFailure>) -> Self {
match outcome {
Outcome::Ok(value) => Self::Ok { value },
Outcome::Err(failure) => Self::Err { failure },
Outcome::Cancelled(reason) => Self::Cancelled {
cancellation: WasmAbiCancellation::from_reason(&reason, CancelPhase::Completed),
},
Outcome::Panicked(payload) => Self::Panicked {
message: payload.message().to_string(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[serde(rename_all = "snake_case")]
pub enum WasmBoundaryState {
Unbound,
Bound,
Active,
Cancelling,
Draining,
Closed,
}
impl WasmBoundaryState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Unbound => "unbound",
Self::Bound => "bound",
Self::Active => "active",
Self::Cancelling => "cancelling",
Self::Draining => "draining",
Self::Closed => "closed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum WasmBoundaryTransitionError {
#[error("invalid wasm boundary transition: {from:?} -> {to:?}")]
Invalid {
from: WasmBoundaryState,
to: WasmBoundaryState,
},
}
#[must_use]
pub fn is_valid_wasm_boundary_transition(from: WasmBoundaryState, to: WasmBoundaryState) -> bool {
if from == to {
return true;
}
matches!(
(from, to),
(WasmBoundaryState::Unbound, WasmBoundaryState::Bound)
| (
WasmBoundaryState::Bound,
WasmBoundaryState::Active | WasmBoundaryState::Closed
)
| (
WasmBoundaryState::Active,
WasmBoundaryState::Cancelling
| WasmBoundaryState::Draining
| WasmBoundaryState::Closed
)
| (
WasmBoundaryState::Cancelling,
WasmBoundaryState::Draining | WasmBoundaryState::Closed
)
| (WasmBoundaryState::Draining, WasmBoundaryState::Closed)
)
}
pub fn validate_wasm_boundary_transition(
from: WasmBoundaryState,
to: WasmBoundaryState,
) -> Result<(), WasmBoundaryTransitionError> {
if is_valid_wasm_boundary_transition(from, to) {
Ok(())
} else {
Err(WasmBoundaryTransitionError::Invalid { from, to })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmAbiBoundaryEvent {
pub abi_version: WasmAbiVersion,
pub symbol: WasmAbiSymbol,
pub payload_shape: WasmAbiPayloadShape,
pub state_from: WasmBoundaryState,
pub state_to: WasmBoundaryState,
pub compatibility: WasmAbiCompatibilityDecision,
}
impl WasmAbiBoundaryEvent {
#[must_use]
pub fn as_log_fields(&self) -> BTreeMap<&'static str, String> {
let mut fields = BTreeMap::new();
fields.insert("abi_version", self.abi_version.to_string());
fields.insert("symbol", self.symbol.as_str().to_string());
fields.insert("payload_shape", self.payload_shape.as_str().to_string());
fields.insert("state_from", self.state_from.as_str().to_string());
fields.insert("state_to", self.state_to.as_str().to_string());
fields.insert(
"compatibility",
self.compatibility.decision_name().to_string(),
);
fields.insert(
"compatibility_decision",
self.compatibility.decision_name().to_string(),
);
fields.insert(
"compatibility_compatible",
self.compatibility.is_compatible().to_string(),
);
match self.compatibility {
WasmAbiCompatibilityDecision::Exact => {
fields.insert(
"compatibility_producer_major",
self.abi_version.major.to_string(),
);
fields.insert(
"compatibility_consumer_major",
self.abi_version.major.to_string(),
);
fields.insert(
"compatibility_producer_minor",
self.abi_version.minor.to_string(),
);
fields.insert(
"compatibility_consumer_minor",
self.abi_version.minor.to_string(),
);
}
WasmAbiCompatibilityDecision::BackwardCompatible {
producer_minor,
consumer_minor,
}
| WasmAbiCompatibilityDecision::ConsumerTooOld {
producer_minor,
consumer_minor,
} => {
fields.insert(
"compatibility_producer_major",
self.abi_version.major.to_string(),
);
fields.insert(
"compatibility_consumer_major",
self.abi_version.major.to_string(),
);
fields.insert("compatibility_producer_minor", producer_minor.to_string());
fields.insert("compatibility_consumer_minor", consumer_minor.to_string());
}
WasmAbiCompatibilityDecision::MajorMismatch {
producer_major,
consumer_major,
} => {
fields.insert("compatibility_producer_major", producer_major.to_string());
fields.insert("compatibility_consumer_major", consumer_major.to_string());
}
}
fields
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmHandleOwnership {
WasmOwned,
TransferredToJs,
Released,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmHandleEntry {
pub handle: WasmHandleRef,
pub parent: Option<WasmHandleRef>,
pub state: WasmBoundaryState,
pub ownership: WasmHandleOwnership,
pub pinned: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WasmHandleError {
#[error("handle slot {slot} out of range (table size {table_size})")]
SlotOutOfRange {
slot: u32,
table_size: u32,
},
#[error("stale handle: slot {slot} generation {expected} != {actual}")]
StaleGeneration {
slot: u32,
expected: u32,
actual: u32,
},
#[error("handle slot {slot} already released")]
AlreadyReleased {
slot: u32,
},
#[error("cannot transfer handle slot {slot}: current ownership is {current:?}")]
InvalidTransfer {
slot: u32,
current: WasmHandleOwnership,
},
#[error("handle slot {slot} is not pinned")]
NotPinned {
slot: u32,
},
#[error("handle slot {slot} is pinned; unpin before releasing")]
ReleasePinned {
slot: u32,
},
#[error("handle slot {slot} is still {state:?}; close before releasing")]
ReleaseBeforeClosed {
slot: u32,
state: WasmBoundaryState,
},
#[error(
"handle slot {slot} still has {live_descendants} live descendant(s); release children first"
)]
ReleaseWithLiveDescendants {
slot: u32,
live_descendants: usize,
},
#[error("ownership cycle detected while traversing slot {slot} from parent slot {parent_slot}")]
OwnershipCycle {
slot: u32,
parent_slot: u32,
},
#[error("invalid state transition for slot {slot}: {from:?} -> {to:?}")]
InvalidStateTransition {
slot: u32,
from: WasmBoundaryState,
to: WasmBoundaryState,
},
}
#[derive(Debug)]
pub struct WasmHandleTable {
slots: Vec<Option<WasmHandleEntry>>,
generations: Vec<u32>,
free_list: Vec<u32>,
live_count: usize,
}
impl Default for WasmHandleTable {
fn default() -> Self {
Self::new()
}
}
impl WasmHandleTable {
#[must_use]
pub fn new() -> Self {
Self {
slots: Vec::new(),
generations: Vec::new(),
free_list: Vec::new(),
live_count: 0,
}
}
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
slots: Vec::with_capacity(capacity),
generations: Vec::with_capacity(capacity),
free_list: Vec::new(),
live_count: 0,
}
}
pub fn allocate(&mut self, kind: WasmHandleKind) -> WasmHandleRef {
self.allocate_with_parent(kind, None)
}
pub fn allocate_with_parent(
&mut self,
kind: WasmHandleKind,
parent: Option<WasmHandleRef>,
) -> WasmHandleRef {
let slot = if let Some(recycled) = self.free_list.pop() {
recycled
} else {
let slot = u32::try_from(self.slots.len()).expect("handle table overflow");
self.slots.push(None);
self.generations.push(0);
slot
};
let generation = self.generations[slot as usize];
let handle = WasmHandleRef {
kind,
slot,
generation,
};
self.slots[slot as usize] = Some(WasmHandleEntry {
handle,
parent,
state: WasmBoundaryState::Unbound,
ownership: WasmHandleOwnership::WasmOwned,
pinned: false,
});
self.live_count += 1;
handle
}
pub fn descendants_postorder(
&self,
root: &WasmHandleRef,
) -> Result<Vec<WasmHandleRef>, WasmHandleError> {
fn visit(
table: &WasmHandleTable,
parent: WasmHandleRef,
visiting: &mut DetHashSet<WasmHandleRef>,
visited: &mut DetHashSet<WasmHandleRef>,
descendants: &mut Vec<WasmHandleRef>,
) -> Result<(), WasmHandleError> {
visiting.insert(parent);
for entry in table.slots.iter().flatten() {
if entry.parent == Some(parent) && entry.ownership != WasmHandleOwnership::Released
{
if visiting.contains(&entry.handle) {
return Err(WasmHandleError::OwnershipCycle {
slot: entry.handle.slot,
parent_slot: parent.slot,
});
}
if visited.insert(entry.handle) {
visit(table, entry.handle, visiting, visited, descendants)?;
}
descendants.push(entry.handle);
}
}
let removed = visiting.remove(&parent);
debug_assert!(removed);
Ok(())
}
let mut descendants = Vec::new();
let mut visiting = DetHashSet::default();
let mut visited = DetHashSet::default();
visit(self, *root, &mut visiting, &mut visited, &mut descendants)?;
Ok(descendants)
}
pub fn get(&self, handle: &WasmHandleRef) -> Result<&WasmHandleEntry, WasmHandleError> {
let slot = handle.slot as usize;
if slot >= self.slots.len() {
return Err(WasmHandleError::SlotOutOfRange {
slot: handle.slot,
table_size: u32::try_from(self.slots.len()).unwrap_or(u32::MAX),
});
}
let current_gen = self.generations[slot];
if handle.generation != current_gen {
return Err(WasmHandleError::StaleGeneration {
slot: handle.slot,
expected: current_gen,
actual: handle.generation,
});
}
self.slots[slot].as_ref().map_or(
Err(WasmHandleError::AlreadyReleased { slot: handle.slot }),
|entry| {
if entry.ownership == WasmHandleOwnership::Released {
Err(WasmHandleError::AlreadyReleased { slot: handle.slot })
} else {
Ok(entry)
}
},
)
}
pub fn get_mut(
&mut self,
handle: &WasmHandleRef,
) -> Result<&mut WasmHandleEntry, WasmHandleError> {
let slot = handle.slot as usize;
if slot >= self.slots.len() {
return Err(WasmHandleError::SlotOutOfRange {
slot: handle.slot,
table_size: u32::try_from(self.slots.len()).unwrap_or(u32::MAX),
});
}
let current_gen = self.generations[slot];
if handle.generation != current_gen {
return Err(WasmHandleError::StaleGeneration {
slot: handle.slot,
expected: current_gen,
actual: handle.generation,
});
}
self.slots[slot].as_mut().map_or(
Err(WasmHandleError::AlreadyReleased { slot: handle.slot }),
|entry| {
if entry.ownership == WasmHandleOwnership::Released {
Err(WasmHandleError::AlreadyReleased { slot: handle.slot })
} else {
Ok(entry)
}
},
)
}
pub fn transition(
&mut self,
handle: &WasmHandleRef,
to: WasmBoundaryState,
) -> Result<(), WasmHandleError> {
let entry = self.get_mut(handle)?;
let from = entry.state;
validate_wasm_boundary_transition(from, to).map_err(|_| {
WasmHandleError::InvalidStateTransition {
slot: handle.slot,
from,
to,
}
})?;
entry.state = to;
Ok(())
}
pub fn pin(&mut self, handle: &WasmHandleRef) -> Result<(), WasmHandleError> {
let entry = self.get_mut(handle)?;
entry.pinned = true;
Ok(())
}
pub fn unpin(&mut self, handle: &WasmHandleRef) -> Result<(), WasmHandleError> {
let entry = self.get_mut(handle)?;
if !entry.pinned {
return Err(WasmHandleError::NotPinned { slot: handle.slot });
}
entry.pinned = false;
Ok(())
}
pub fn transfer_to_js(&mut self, handle: &WasmHandleRef) -> Result<(), WasmHandleError> {
let entry = self.get_mut(handle)?;
if entry.ownership != WasmHandleOwnership::WasmOwned {
return Err(WasmHandleError::InvalidTransfer {
slot: handle.slot,
current: entry.ownership,
});
}
entry.ownership = WasmHandleOwnership::TransferredToJs;
Ok(())
}
pub fn release(&mut self, handle: &WasmHandleRef) -> Result<(), WasmHandleError> {
let entry = self.get(handle)?;
if entry.pinned {
return Err(WasmHandleError::ReleasePinned { slot: handle.slot });
}
if entry.state != WasmBoundaryState::Closed {
return Err(WasmHandleError::ReleaseBeforeClosed {
slot: handle.slot,
state: entry.state,
});
}
let live_descendants = self.descendants_postorder(handle)?.len();
if live_descendants != 0 {
return Err(WasmHandleError::ReleaseWithLiveDescendants {
slot: handle.slot,
live_descendants,
});
}
let entry = self.get_mut(handle)?;
entry.ownership = WasmHandleOwnership::Released;
self.slots[handle.slot as usize] = None;
self.generations[handle.slot as usize] =
self.generations[handle.slot as usize].wrapping_add(1);
self.free_list.push(handle.slot);
self.live_count -= 1;
Ok(())
}
#[must_use]
pub fn live_count(&self) -> usize {
self.live_count
}
#[must_use]
pub fn capacity(&self) -> usize {
self.slots.len()
}
#[must_use]
pub fn memory_report(&self) -> WasmMemoryReport {
let mut by_kind = BTreeMap::new();
let mut by_state = BTreeMap::new();
let mut pinned_count: usize = 0;
for entry in self.slots.iter().flatten() {
if entry.ownership != WasmHandleOwnership::Released {
*by_kind
.entry(format!("{:?}", entry.handle.kind).to_lowercase())
.or_insert(0usize) += 1;
*by_state
.entry(format!("{:?}", entry.state).to_lowercase())
.or_insert(0usize) += 1;
if entry.pinned {
pinned_count += 1;
}
}
}
WasmMemoryReport {
live_handles: self.live_count,
capacity: self.slots.len(),
free_slots: self.free_list.len(),
pinned_count,
by_kind,
by_state,
}
}
#[must_use]
pub fn detect_leaks(&self) -> Vec<WasmHandleRef> {
self.slots
.iter()
.flatten()
.filter(|entry| {
entry.state == WasmBoundaryState::Closed
&& entry.ownership != WasmHandleOwnership::Released
})
.map(|entry| entry.handle)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmMemoryReport {
pub live_handles: usize,
pub capacity: usize,
pub free_slots: usize,
pub pinned_count: usize,
pub by_kind: BTreeMap<String, usize>,
pub by_state: BTreeMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WasmBufferTransfer {
pub source_handle: WasmHandleRef,
pub byte_length: u64,
pub mode: WasmBufferTransferMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmBufferTransferMode {
Copy,
Transfer,
}
impl WasmBufferTransferMode {
#[must_use]
pub const fn is_copy(self) -> bool {
matches!(self, Self::Copy)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmHandleLifecycleEvent {
pub handle: WasmHandleRef,
pub event: WasmHandleEventKind,
pub ownership_before: WasmHandleOwnership,
pub ownership_after: WasmHandleOwnership,
pub state_before: WasmBoundaryState,
pub state_after: WasmBoundaryState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WasmHandleEventKind {
Allocated,
StateTransition,
Pinned,
Unpinned,
TransferredToJs,
Released,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmScopeEnterRequest {
pub parent: WasmHandleRef,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmTaskSpawnRequest {
pub scope: WasmHandleRef,
pub label: Option<String>,
pub cancel_kind: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmTaskCancelRequest {
pub task: WasmHandleRef,
pub kind: String,
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmFetchRequest {
pub scope: WasmHandleRef,
pub url: String,
pub method: String,
pub body: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WasmExportResult {
Handle(WasmHandleRef),
Outcome(WasmAbiOutcomeEnvelope),
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum WasmDispatchError {
#[error("ABI incompatible: {decision:?}")]
Incompatible {
decision: WasmAbiCompatibilityDecision,
},
#[error("handle error: {0}")]
Handle(#[from] WasmHandleError),
#[error("invalid boundary state {state:?} for symbol {symbol:?}")]
InvalidState {
state: WasmBoundaryState,
symbol: WasmAbiSymbol,
},
#[error("invalid request: {reason}")]
InvalidRequest {
reason: String,
},
}
impl WasmDispatchError {
#[must_use]
pub fn to_failure(&self) -> WasmAbiFailure {
match self {
Self::Incompatible { .. } => WasmAbiFailure {
code: WasmAbiErrorCode::CompatibilityRejected,
recoverability: WasmAbiRecoverability::Permanent,
message: self.to_string(),
},
Self::Handle(_) | Self::InvalidState { .. } => WasmAbiFailure {
code: WasmAbiErrorCode::InvalidHandle,
recoverability: WasmAbiRecoverability::Permanent,
message: self.to_string(),
},
Self::InvalidRequest { .. } => WasmAbiFailure {
code: WasmAbiErrorCode::DecodeFailure,
recoverability: WasmAbiRecoverability::Permanent,
message: self.to_string(),
},
}
}
#[must_use]
pub fn to_outcome(&self) -> WasmAbiOutcomeEnvelope {
WasmAbiOutcomeEnvelope::Err {
failure: self.to_failure(),
}
}
}
#[derive(Debug, Default)]
pub struct WasmBoundaryEventLog {
events: Vec<WasmAbiBoundaryEvent>,
}
impl WasmBoundaryEventLog {
#[must_use]
pub fn new() -> Self {
Self { events: Vec::new() }
}
pub fn record(&mut self, event: WasmAbiBoundaryEvent) {
self.events.push(event);
}
#[must_use]
pub fn events(&self) -> &[WasmAbiBoundaryEvent] {
&self.events
}
pub fn drain(&mut self) -> Vec<WasmAbiBoundaryEvent> {
std::mem::take(&mut self.events)
}
#[must_use]
pub fn len(&self) -> usize {
self.events.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
#[derive(Debug)]
pub struct WasmExportDispatcher {
handles: WasmHandleTable,
event_log: WasmBoundaryEventLog,
producer_version: WasmAbiVersion,
abort_mode: WasmAbortPropagationMode,
dispatch_count: u64,
}
impl Default for WasmExportDispatcher {
fn default() -> Self {
Self::new()
}
}
impl WasmExportDispatcher {
#[must_use]
pub fn new() -> Self {
Self {
handles: WasmHandleTable::new(),
event_log: WasmBoundaryEventLog::new(),
producer_version: WasmAbiVersion::CURRENT,
abort_mode: WasmAbortPropagationMode::Bidirectional,
dispatch_count: 0,
}
}
#[must_use]
pub fn with_abort_mode(mut self, mode: WasmAbortPropagationMode) -> Self {
self.abort_mode = mode;
self
}
#[must_use]
pub fn handles(&self) -> &WasmHandleTable {
&self.handles
}
pub fn handles_mut(&mut self) -> &mut WasmHandleTable {
&mut self.handles
}
#[must_use]
pub fn event_log(&self) -> &WasmBoundaryEventLog {
&self.event_log
}
pub fn event_log_mut(&mut self) -> &mut WasmBoundaryEventLog {
&mut self.event_log
}
#[must_use]
pub fn dispatch_count(&self) -> u64 {
self.dispatch_count
}
fn check_compat(
&self,
consumer: Option<WasmAbiVersion>,
) -> Result<WasmAbiCompatibilityDecision, WasmDispatchError> {
let consumer = consumer.unwrap_or(self.producer_version);
let decision = classify_wasm_abi_compatibility(self.producer_version, consumer);
if decision.is_compatible() {
Ok(decision)
} else {
Err(WasmDispatchError::Incompatible { decision })
}
}
fn emit_event(
&mut self,
symbol: WasmAbiSymbol,
state_from: WasmBoundaryState,
state_to: WasmBoundaryState,
compatibility: WasmAbiCompatibilityDecision,
) {
let sig = WASM_ABI_SIGNATURES_V1
.iter()
.find(|s| s.symbol == symbol)
.expect("symbol not in signature table");
self.event_log.record(WasmAbiBoundaryEvent {
abi_version: self.producer_version,
symbol,
payload_shape: sig.request,
state_from,
state_to,
compatibility,
});
}
fn drain_and_release_handle(
&mut self,
handle: &WasmHandleRef,
) -> Result<WasmBoundaryState, WasmDispatchError> {
let state_from = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.state;
for target in [
WasmBoundaryState::Cancelling,
WasmBoundaryState::Draining,
WasmBoundaryState::Closed,
] {
let current = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.state;
if is_valid_wasm_boundary_transition(current, target) {
self.handles
.transition(handle, target)
.map_err(WasmDispatchError::Handle)?;
}
}
if self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.pinned
{
self.handles
.unpin(handle)
.map_err(WasmDispatchError::Handle)?;
}
self.handles
.release(handle)
.map_err(WasmDispatchError::Handle)?;
Ok(state_from)
}
fn close_handle_tree(
&mut self,
root: &WasmHandleRef,
) -> Result<WasmBoundaryState, WasmDispatchError> {
let descendants = self
.handles
.descendants_postorder(root)
.map_err(WasmDispatchError::Handle)?;
for descendant in descendants {
self.drain_and_release_handle(&descendant)?;
}
self.drain_and_release_handle(root)
}
fn require_active_runtime_or_region_handle(
&self,
handle: &WasmHandleRef,
symbol: WasmAbiSymbol,
role: &'static str,
) -> Result<(), WasmDispatchError> {
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
if entry.state != WasmBoundaryState::Active {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol,
});
}
if !matches!(
entry.handle.kind,
WasmHandleKind::Region | WasmHandleKind::Runtime
) {
return Err(WasmDispatchError::InvalidRequest {
reason: format!(
"{role} requires Region or Runtime handle, got {:?}",
entry.handle.kind
),
});
}
Ok(())
}
pub fn runtime_create(
&mut self,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmHandleRef, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
let handle = self.handles.allocate(WasmHandleKind::Runtime);
self.handles
.transition(&handle, WasmBoundaryState::Bound)
.map_err(WasmDispatchError::Handle)?;
self.handles
.transition(&handle, WasmBoundaryState::Active)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::RuntimeCreate,
WasmBoundaryState::Unbound,
WasmBoundaryState::Active,
compat,
);
Ok(handle)
}
pub fn runtime_close(
&mut self,
handle: &WasmHandleRef,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
if entry.handle.kind != WasmHandleKind::Runtime {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol: WasmAbiSymbol::RuntimeClose,
});
}
let state_from = self.close_handle_tree(handle)?;
self.emit_event(
WasmAbiSymbol::RuntimeClose,
state_from,
WasmBoundaryState::Closed,
compat,
);
Ok(WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
})
}
pub fn scope_enter(
&mut self,
request: &WasmScopeEnterRequest,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmHandleRef, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
self.require_active_runtime_or_region_handle(
&request.parent,
WasmAbiSymbol::ScopeEnter,
"scope_enter parent",
)?;
let handle = self
.handles
.allocate_with_parent(WasmHandleKind::Region, Some(request.parent));
self.handles
.transition(&handle, WasmBoundaryState::Bound)
.map_err(WasmDispatchError::Handle)?;
self.handles
.transition(&handle, WasmBoundaryState::Active)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::ScopeEnter,
WasmBoundaryState::Unbound,
WasmBoundaryState::Active,
compat,
);
Ok(handle)
}
pub fn scope_close(
&mut self,
handle: &WasmHandleRef,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
if entry.handle.kind != WasmHandleKind::Region {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol: WasmAbiSymbol::ScopeClose,
});
}
let state_from = self.close_handle_tree(handle)?;
self.emit_event(
WasmAbiSymbol::ScopeClose,
state_from,
WasmBoundaryState::Closed,
compat,
);
Ok(WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
})
}
pub fn task_spawn(
&mut self,
request: &WasmTaskSpawnRequest,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmHandleRef, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
self.require_active_runtime_or_region_handle(
&request.scope,
WasmAbiSymbol::TaskSpawn,
"task_spawn scope",
)?;
let handle = self
.handles
.allocate_with_parent(WasmHandleKind::Task, Some(request.scope));
self.handles
.transition(&handle, WasmBoundaryState::Bound)
.map_err(WasmDispatchError::Handle)?;
self.handles
.transition(&handle, WasmBoundaryState::Active)
.map_err(WasmDispatchError::Handle)?;
self.handles
.pin(&handle)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::TaskSpawn,
WasmBoundaryState::Unbound,
WasmBoundaryState::Active,
compat,
);
Ok(handle)
}
pub fn task_join(
&mut self,
handle: &WasmHandleRef,
outcome: WasmAbiOutcomeEnvelope,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
if entry.handle.kind != WasmHandleKind::Task {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol: WasmAbiSymbol::TaskJoin,
});
}
let state_from = entry.state;
for target in [WasmBoundaryState::Draining, WasmBoundaryState::Closed] {
if is_valid_wasm_boundary_transition(
self.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.state,
target,
) {
self.handles
.transition(handle, target)
.map_err(WasmDispatchError::Handle)?;
}
}
if self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.pinned
{
self.handles
.unpin(handle)
.map_err(WasmDispatchError::Handle)?;
}
self.handles
.release(handle)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::TaskJoin,
state_from,
WasmBoundaryState::Closed,
compat,
);
Ok(outcome)
}
pub fn task_cancel(
&mut self,
request: &WasmTaskCancelRequest,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
let entry = self
.handles
.get(&request.task)
.map_err(WasmDispatchError::Handle)?;
if entry.handle.kind != WasmHandleKind::Task {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol: WasmAbiSymbol::TaskCancel,
});
}
let state_from = entry.state;
if state_from != WasmBoundaryState::Active {
return Err(WasmDispatchError::InvalidState {
state: state_from,
symbol: WasmAbiSymbol::TaskCancel,
});
}
self.handles
.transition(&request.task, WasmBoundaryState::Cancelling)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::TaskCancel,
state_from,
WasmBoundaryState::Cancelling,
compat,
);
Ok(WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
})
}
pub fn fetch_request(
&mut self,
request: &WasmFetchRequest,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmHandleRef, WasmDispatchError> {
self.dispatch_count += 1;
let compat = self.check_compat(consumer_version)?;
self.require_active_runtime_or_region_handle(
&request.scope,
WasmAbiSymbol::FetchRequest,
"fetch_request scope",
)?;
if request.url.is_empty() {
return Err(WasmDispatchError::InvalidRequest {
reason: "fetch URL must not be empty".to_string(),
});
}
let handle = self
.handles
.allocate_with_parent(WasmHandleKind::FetchRequest, Some(request.scope));
self.handles
.transition(&handle, WasmBoundaryState::Bound)
.map_err(WasmDispatchError::Handle)?;
self.handles
.transition(&handle, WasmBoundaryState::Active)
.map_err(WasmDispatchError::Handle)?;
self.handles
.pin(&handle)
.map_err(WasmDispatchError::Handle)?;
self.emit_event(
WasmAbiSymbol::FetchRequest,
WasmBoundaryState::Unbound,
WasmBoundaryState::Active,
compat,
);
Ok(handle)
}
pub fn fetch_complete(
&mut self,
handle: &WasmHandleRef,
outcome: WasmAbiOutcomeEnvelope,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.dispatch_count += 1;
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
if entry.handle.kind != WasmHandleKind::FetchRequest {
return Err(WasmDispatchError::InvalidState {
state: entry.state,
symbol: WasmAbiSymbol::FetchRequest,
});
}
for target in [WasmBoundaryState::Draining, WasmBoundaryState::Closed] {
if is_valid_wasm_boundary_transition(
self.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.state,
target,
) {
self.handles
.transition(handle, target)
.map_err(WasmDispatchError::Handle)?;
}
}
if self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?
.pinned
{
self.handles
.unpin(handle)
.map_err(WasmDispatchError::Handle)?;
}
self.handles
.release(handle)
.map_err(WasmDispatchError::Handle)?;
Ok(outcome)
}
pub fn apply_abort(
&mut self,
handle: &WasmHandleRef,
) -> Result<WasmAbortInteropUpdate, WasmDispatchError> {
let entry = self
.handles
.get(handle)
.map_err(WasmDispatchError::Handle)?;
let snapshot = WasmAbortInteropSnapshot {
mode: self.abort_mode,
boundary_state: entry.state,
abort_signal_aborted: false,
};
let update = apply_abort_signal_event(snapshot);
if update.next_boundary_state != entry.state {
self.handles
.transition(handle, update.next_boundary_state)
.map_err(WasmDispatchError::Handle)?;
}
Ok(update)
}
}
#[derive(Debug, Clone)]
pub struct WasmScopeEnterBuilder {
parent: WasmHandleRef,
label: Option<String>,
}
impl WasmScopeEnterBuilder {
#[must_use]
pub fn new(parent: WasmHandleRef) -> Self {
Self {
parent,
label: None,
}
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn build(self) -> WasmScopeEnterRequest {
WasmScopeEnterRequest {
parent: self.parent,
label: self.label,
}
}
}
#[derive(Debug, Clone)]
pub struct WasmTaskSpawnBuilder {
scope: WasmHandleRef,
label: Option<String>,
cancel_kind: Option<String>,
}
impl WasmTaskSpawnBuilder {
#[must_use]
pub fn new(scope: WasmHandleRef) -> Self {
Self {
scope,
label: None,
cancel_kind: None,
}
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
#[must_use]
pub fn cancel_kind(mut self, kind: impl Into<String>) -> Self {
self.cancel_kind = Some(kind.into());
self
}
#[must_use]
pub fn build(self) -> WasmTaskSpawnRequest {
WasmTaskSpawnRequest {
scope: self.scope,
label: self.label,
cancel_kind: self.cancel_kind,
}
}
}
#[derive(Debug, Clone)]
pub struct WasmFetchBuilder {
scope: WasmHandleRef,
url: String,
method: String,
body: Option<Vec<u8>>,
}
impl WasmFetchBuilder {
#[must_use]
pub fn new(scope: WasmHandleRef, url: impl Into<String>) -> Self {
Self {
scope,
url: url.into(),
method: "GET".to_string(),
body: None,
}
}
#[must_use]
pub fn method(mut self, method: impl Into<String>) -> Self {
self.method = method.into();
self
}
#[must_use]
pub fn body(mut self, body: Vec<u8>) -> Self {
self.body = Some(body);
self
}
#[must_use]
pub fn build(self) -> WasmFetchRequest {
WasmFetchRequest {
scope: self.scope,
url: self.url,
method: self.method,
body: self.body,
}
}
}
pub trait WasmOutcomeExt {
fn is_ok(&self) -> bool;
fn is_err(&self) -> bool;
fn is_cancelled(&self) -> bool;
fn is_panicked(&self) -> bool;
fn ok_value(&self) -> Option<&WasmAbiValue>;
fn err_failure(&self) -> Option<&WasmAbiFailure>;
fn cancellation(&self) -> Option<&WasmAbiCancellation>;
fn outcome_kind(&self) -> &'static str;
}
impl WasmOutcomeExt for WasmAbiOutcomeEnvelope {
fn is_ok(&self) -> bool {
matches!(self, Self::Ok { .. })
}
fn is_err(&self) -> bool {
matches!(self, Self::Err { .. })
}
fn is_cancelled(&self) -> bool {
matches!(self, Self::Cancelled { .. })
}
fn is_panicked(&self) -> bool {
matches!(self, Self::Panicked { .. })
}
fn ok_value(&self) -> Option<&WasmAbiValue> {
match self {
Self::Ok { value } => Some(value),
_ => None,
}
}
fn err_failure(&self) -> Option<&WasmAbiFailure> {
match self {
Self::Err { failure } => Some(failure),
_ => None,
}
}
fn cancellation(&self) -> Option<&WasmAbiCancellation> {
match self {
Self::Cancelled { cancellation } => Some(cancellation),
_ => None,
}
}
fn outcome_kind(&self) -> &'static str {
match self {
Self::Ok { .. } => "ok",
Self::Err { .. } => "err",
Self::Cancelled { .. } => "cancelled",
Self::Panicked { .. } => "panicked",
}
}
}
impl WasmExportDispatcher {
pub fn create_scoped_runtime(
&mut self,
scope_label: Option<&str>,
consumer_version: Option<WasmAbiVersion>,
) -> Result<(WasmHandleRef, WasmHandleRef), WasmDispatchError> {
let runtime = self.runtime_create(consumer_version)?;
let scope = self.scope_enter(
&WasmScopeEnterBuilder::new(runtime)
.label(scope_label.unwrap_or("root"))
.build(),
consumer_version,
)?;
Ok((runtime, scope))
}
pub fn spawn(
&mut self,
request: WasmTaskSpawnBuilder,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmHandleRef, WasmDispatchError> {
self.task_spawn(&request.build(), consumer_version)
}
pub fn spawn_and_join(
&mut self,
scope: WasmHandleRef,
label: Option<&str>,
outcome: WasmAbiOutcomeEnvelope,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
let mut builder = WasmTaskSpawnBuilder::new(scope);
if let Some(l) = label {
builder = builder.label(l);
}
let task = self.task_spawn(&builder.build(), consumer_version)?;
self.task_join(&task, outcome, consumer_version)
}
pub fn close_scoped_runtime(
&mut self,
scope: &WasmHandleRef,
runtime: &WasmHandleRef,
consumer_version: Option<WasmAbiVersion>,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
self.scope_close(scope, consumer_version)?;
self.runtime_close(runtime, consumer_version)
}
#[must_use]
pub fn diagnostic_snapshot(&self) -> WasmDispatcherDiagnostics {
WasmDispatcherDiagnostics {
dispatch_count: self.dispatch_count,
memory_report: self.handles.memory_report(),
event_count: self.event_log.len(),
leaks: self.handles.detect_leaks(),
producer_version: self.producer_version,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WasmDispatcherDiagnostics {
pub dispatch_count: u64,
pub memory_report: WasmMemoryReport,
pub event_count: usize,
pub leaks: Vec<WasmHandleRef>,
pub producer_version: WasmAbiVersion,
}
impl WasmDispatcherDiagnostics {
#[must_use]
pub fn is_clean(&self) -> bool {
self.leaks.is_empty() && self.memory_report.live_handles == 0
}
#[must_use]
pub fn as_log_fields(&self) -> BTreeMap<&'static str, String> {
let mut fields = BTreeMap::new();
fields.insert("dispatch_count", self.dispatch_count.to_string());
fields.insert("live_handles", self.memory_report.live_handles.to_string());
fields.insert("pinned_count", self.memory_report.pinned_count.to_string());
fields.insert("event_count", self.event_count.to_string());
fields.insert("leak_count", self.leaks.len().to_string());
fields.insert("abi_version", self.producer_version.to_string());
fields.insert("clean", self.is_clean().to_string());
fields
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReactProviderPhase {
Pending,
Initializing,
Ready,
Disposing,
Disposed,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid provider transition: {from:?} -> {to:?}")]
pub struct ReactProviderTransitionError {
pub from: ReactProviderPhase,
pub to: ReactProviderPhase,
}
#[must_use]
pub fn is_valid_provider_transition(from: ReactProviderPhase, to: ReactProviderPhase) -> bool {
if from == to {
return true;
}
matches!(
(from, to),
(
ReactProviderPhase::Pending | ReactProviderPhase::Disposed,
ReactProviderPhase::Initializing
) | (
ReactProviderPhase::Initializing,
ReactProviderPhase::Ready | ReactProviderPhase::Failed
) | (ReactProviderPhase::Ready, ReactProviderPhase::Disposing)
| (
ReactProviderPhase::Disposing,
ReactProviderPhase::Disposed | ReactProviderPhase::Failed
)
)
}
pub fn validate_provider_transition(
from: ReactProviderPhase,
to: ReactProviderPhase,
) -> Result<(), ReactProviderTransitionError> {
if is_valid_provider_transition(from, to) {
Ok(())
} else {
Err(ReactProviderTransitionError { from, to })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactProviderConfig {
pub label: String,
pub consumer_version: Option<WasmAbiVersion>,
pub abort_mode: WasmAbortPropagationMode,
pub strict_mode_resilient: bool,
pub devtools_diagnostics: bool,
}
impl Default for ReactProviderConfig {
fn default() -> Self {
Self {
label: "asupersync".to_string(),
consumer_version: None,
abort_mode: WasmAbortPropagationMode::Bidirectional,
strict_mode_resilient: true,
devtools_diagnostics: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactProviderSnapshot {
pub phase: ReactProviderPhase,
pub config: ReactProviderConfig,
pub runtime_handle: Option<WasmHandleRef>,
pub root_scope_handle: Option<WasmHandleRef>,
pub child_scope_count: usize,
pub active_task_count: usize,
pub transition_history: Vec<ReactProviderPhase>,
pub dispatcher_diagnostics: Option<WasmDispatcherDiagnostics>,
}
#[derive(Debug)]
pub struct ReactProviderState {
phase: ReactProviderPhase,
config: ReactProviderConfig,
dispatcher: WasmExportDispatcher,
runtime_handle: Option<WasmHandleRef>,
root_scope_handle: Option<WasmHandleRef>,
child_scopes: Vec<WasmHandleRef>,
active_tasks: Vec<WasmHandleRef>,
transition_history: Vec<ReactProviderPhase>,
}
impl ReactProviderState {
fn owns_scope_handle(&self, scope: WasmHandleRef) -> bool {
self.root_scope_handle == Some(scope) || self.child_scopes.contains(&scope)
}
fn tracks_task_handle(&self, task: &WasmHandleRef) -> bool {
self.active_tasks.contains(task)
}
#[must_use]
pub fn new(config: ReactProviderConfig) -> Self {
let dispatcher = WasmExportDispatcher::new().with_abort_mode(config.abort_mode);
Self {
phase: ReactProviderPhase::Pending,
config,
dispatcher,
runtime_handle: None,
root_scope_handle: None,
child_scopes: Vec::new(),
active_tasks: Vec::new(),
transition_history: vec![ReactProviderPhase::Pending],
}
}
#[must_use]
pub fn phase(&self) -> ReactProviderPhase {
self.phase
}
#[must_use]
pub fn runtime_handle(&self) -> Option<WasmHandleRef> {
self.runtime_handle
}
#[must_use]
pub fn root_scope_handle(&self) -> Option<WasmHandleRef> {
self.root_scope_handle
}
#[must_use]
pub fn dispatcher(&self) -> &WasmExportDispatcher {
&self.dispatcher
}
fn advance(&mut self, to: ReactProviderPhase) -> Result<(), ReactProviderTransitionError> {
const MAX_HISTORY: usize = 256;
validate_provider_transition(self.phase, to)?;
self.phase = to;
if self.transition_history.len() >= MAX_HISTORY {
self.transition_history.drain(..MAX_HISTORY / 2);
}
self.transition_history.push(to);
Ok(())
}
pub fn mount(&mut self) -> Result<(), WasmDispatchError> {
self.advance(ReactProviderPhase::Initializing)
.map_err(|e| WasmDispatchError::InvalidRequest {
reason: e.to_string(),
})?;
match self.do_mount() {
Ok(()) => {
self.advance(ReactProviderPhase::Ready).map_err(|e| {
WasmDispatchError::InvalidRequest {
reason: e.to_string(),
}
})?;
Ok(())
}
Err(e) => {
let _ = self.advance(ReactProviderPhase::Failed);
Err(e)
}
}
}
fn do_mount(&mut self) -> Result<(), WasmDispatchError> {
let (rt, scope) = self
.dispatcher
.create_scoped_runtime(Some(&self.config.label), self.config.consumer_version)?;
self.runtime_handle = Some(rt);
self.root_scope_handle = Some(scope);
Ok(())
}
pub fn unmount(&mut self) -> Result<(), WasmDispatchError> {
self.advance(ReactProviderPhase::Disposing).map_err(|e| {
WasmDispatchError::InvalidRequest {
reason: e.to_string(),
}
})?;
match self.do_unmount() {
Ok(()) => {
self.advance(ReactProviderPhase::Disposed).map_err(|e| {
WasmDispatchError::InvalidRequest {
reason: e.to_string(),
}
})?;
Ok(())
}
Err(e) => {
let _ = self.advance(ReactProviderPhase::Failed);
Err(e)
}
}
}
#[allow(clippy::too_many_lines)]
fn do_unmount(&mut self) -> Result<(), WasmDispatchError> {
let cv = self.config.consumer_version;
let mut remaining_tasks = Vec::new();
for task in std::mem::take(&mut self.active_tasks) {
if self.dispatcher.handles().get(&task).is_ok() {
let state = self
.dispatcher
.handles()
.get(&task)
.map_or(WasmBoundaryState::Closed, |e| e.state);
if state == WasmBoundaryState::Active {
let _ = self.dispatcher.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "unmount".to_string(),
message: Some("React component unmounted".to_string()),
},
cv,
);
}
if self.dispatcher.handles().get(&task).is_ok() {
let _ = self.dispatcher.task_join(
&task,
WasmAbiOutcomeEnvelope::Cancelled {
cancellation: WasmAbiCancellation {
kind: "unmount".to_string(),
phase: "completed".to_string(),
origin_region: "react-provider".to_string(),
origin_task: None,
timestamp_nanos: 0,
message: Some("component unmounted".to_string()),
truncated: false,
},
},
cv,
);
}
}
if self.dispatcher.handles().get(&task).is_ok() {
remaining_tasks.push(task);
}
}
self.active_tasks = remaining_tasks;
let mut remaining_child_scopes = Vec::new();
let child_scopes: Vec<_> = self.child_scopes.drain(..).rev().collect();
for scope in child_scopes {
if self.dispatcher.handles().get(&scope).is_ok() {
let _ = self.dispatcher.scope_close(&scope, cv);
}
if self.dispatcher.handles().get(&scope).is_ok() {
remaining_child_scopes.push(scope);
}
}
remaining_child_scopes.reverse();
self.child_scopes = remaining_child_scopes;
if let Some(scope) = self.root_scope_handle {
if self.dispatcher.handles().get(&scope).is_ok() {
self.dispatcher.scope_close(&scope, cv)?;
}
self.root_scope_handle = None;
}
if let Some(rt) = self.runtime_handle {
if self.dispatcher.handles().get(&rt).is_ok() {
self.dispatcher.runtime_close(&rt, cv)?;
}
self.runtime_handle = None;
}
Ok(())
}
pub fn create_child_scope(
&mut self,
label: Option<&str>,
) -> Result<WasmHandleRef, WasmDispatchError> {
if self.phase != ReactProviderPhase::Ready {
return Err(WasmDispatchError::InvalidState {
state: WasmBoundaryState::Closed,
symbol: WasmAbiSymbol::ScopeEnter,
});
}
let parent = self
.root_scope_handle
.ok_or_else(|| WasmDispatchError::InvalidRequest {
reason: "no root scope".to_string(),
})?;
let scope = self.dispatcher.scope_enter(
&WasmScopeEnterBuilder::new(parent)
.label(label.unwrap_or("child"))
.build(),
self.config.consumer_version,
)?;
self.child_scopes.push(scope);
Ok(scope)
}
pub fn spawn_task(
&mut self,
scope: WasmHandleRef,
label: Option<&str>,
) -> Result<WasmHandleRef, WasmDispatchError> {
if self.phase != ReactProviderPhase::Ready {
return Err(WasmDispatchError::InvalidState {
state: WasmBoundaryState::Closed,
symbol: WasmAbiSymbol::TaskSpawn,
});
}
if !self.owns_scope_handle(scope) {
return Err(WasmDispatchError::InvalidRequest {
reason: "scope not owned by provider".to_string(),
});
}
let task = self.dispatcher.spawn(
{
let mut b = WasmTaskSpawnBuilder::new(scope);
if let Some(l) = label {
b = b.label(l);
}
b
},
self.config.consumer_version,
)?;
self.active_tasks.push(task);
Ok(task)
}
pub fn complete_task(
&mut self,
task: &WasmHandleRef,
outcome: WasmAbiOutcomeEnvelope,
) -> Result<WasmAbiOutcomeEnvelope, WasmDispatchError> {
if !self.tracks_task_handle(task) {
return Err(WasmDispatchError::InvalidRequest {
reason: "task not tracked by provider".to_string(),
});
}
let result = self
.dispatcher
.task_join(task, outcome, self.config.consumer_version);
if result.is_ok() {
self.active_tasks.retain(|t| t != task);
}
result
}
#[must_use]
pub fn snapshot(&self) -> ReactProviderSnapshot {
ReactProviderSnapshot {
phase: self.phase,
config: self.config.clone(),
runtime_handle: self.runtime_handle,
root_scope_handle: self.root_scope_handle,
child_scope_count: self.child_scopes.len(),
active_task_count: self.active_tasks.len(),
transition_history: self.transition_history.clone(),
dispatcher_diagnostics: Some(self.dispatcher.diagnostic_snapshot()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReactHookPhase {
Idle,
Active,
Cleanup,
Unmounted,
Error,
}
#[must_use]
pub fn is_valid_hook_transition(from: ReactHookPhase, to: ReactHookPhase) -> bool {
if from == to {
return true;
}
matches!(
(from, to),
(
ReactHookPhase::Idle,
ReactHookPhase::Active | ReactHookPhase::Error
) | (ReactHookPhase::Active, ReactHookPhase::Cleanup)
| (
ReactHookPhase::Cleanup,
ReactHookPhase::Unmounted | ReactHookPhase::Active | ReactHookPhase::Error
)
| (ReactHookPhase::Unmounted, ReactHookPhase::Active)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid hook transition: {from:?} -> {to:?}")]
pub struct ReactHookTransitionError {
pub from: ReactHookPhase,
pub to: ReactHookPhase,
}
pub fn validate_hook_transition(
from: ReactHookPhase,
to: ReactHookPhase,
) -> Result<(), ReactHookTransitionError> {
if is_valid_hook_transition(from, to) {
Ok(())
} else {
Err(ReactHookTransitionError { from, to })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReactHookKind {
Scope,
Task,
Race,
Cancellation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseScopeConfig {
pub label: String,
pub propagate_cancel: bool,
}
impl Default for UseScopeConfig {
fn default() -> Self {
Self {
label: "scope".to_string(),
propagate_cancel: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseScopeSnapshot {
pub phase: ReactHookPhase,
pub config: UseScopeConfig,
pub scope_handle: Option<WasmHandleRef>,
pub task_count: usize,
pub child_scope_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskDepChangePolicy {
CancelAndRestart,
DiscardAndRestart,
KeepRunning,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseTaskConfig {
pub label: String,
pub dep_change_policy: TaskDepChangePolicy,
pub memoize_result: bool,
}
impl Default for UseTaskConfig {
fn default() -> Self {
Self {
label: "task".to_string(),
dep_change_policy: TaskDepChangePolicy::CancelAndRestart,
memoize_result: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UseTaskStatus {
Idle,
Running,
Success,
Error,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseTaskSnapshot {
pub phase: ReactHookPhase,
pub status: UseTaskStatus,
pub config: UseTaskConfig,
pub task_handle: Option<WasmHandleRef>,
pub scope_handle: Option<WasmHandleRef>,
pub spawn_count: u32,
pub dep_cancel_count: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseRaceConfig {
pub label: String,
pub max_racers: usize,
pub drain_losers_before_resolve: bool,
}
impl Default for UseRaceConfig {
fn default() -> Self {
Self {
label: "race".to_string(),
max_racers: 8,
drain_losers_before_resolve: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RacerState {
Running,
Won,
Draining,
Drained,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RacerSnapshot {
pub index: usize,
pub state: RacerState,
pub task_handle: WasmHandleRef,
pub label: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseRaceSnapshot {
pub phase: ReactHookPhase,
pub config: UseRaceConfig,
pub scope_handle: Option<WasmHandleRef>,
pub racers: Vec<RacerSnapshot>,
pub race_count: u32,
pub has_winner: bool,
pub losers_drained: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseCancellationConfig {
pub label: String,
pub can_trigger: bool,
}
impl Default for UseCancellationConfig {
fn default() -> Self {
Self {
label: "cancellation".to_string(),
can_trigger: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UseCancellationSnapshot {
pub phase: ReactHookPhase,
pub config: UseCancellationConfig,
pub scope_handle: Option<WasmHandleRef>,
pub is_cancelled: bool,
pub cancellation: Option<WasmAbiCancellation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReactHookDiagnosticEvent {
pub hook_kind: ReactHookKind,
pub label: String,
pub from_phase: ReactHookPhase,
pub to_phase: ReactHookPhase,
pub handles: Vec<WasmHandleRef>,
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsRenderEnvironment {
ServerComponent,
ClientSsr,
ClientHydrated,
EdgeRuntime,
NodeServer,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsBoundaryMode {
Client,
Server,
Edge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsRuntimeFallback {
NoneRequired,
DeferUntilHydrated,
UseServerBridge,
UseEdgeBridge,
}
impl NextjsRenderEnvironment {
#[must_use]
pub fn supports_wasm_runtime(self) -> bool {
self == Self::ClientHydrated
}
#[must_use]
pub fn has_browser_apis(self) -> bool {
matches!(self, Self::ClientSsr | Self::ClientHydrated)
}
#[must_use]
pub fn is_server_side(self) -> bool {
matches!(
self,
Self::ServerComponent | Self::EdgeRuntime | Self::NodeServer
)
}
#[must_use]
pub fn boundary_mode(self) -> NextjsBoundaryMode {
match self {
Self::ClientSsr | Self::ClientHydrated => NextjsBoundaryMode::Client,
Self::ServerComponent | Self::NodeServer => NextjsBoundaryMode::Server,
Self::EdgeRuntime => NextjsBoundaryMode::Edge,
}
}
#[must_use]
pub fn runtime_fallback(self) -> NextjsRuntimeFallback {
match self {
Self::ClientHydrated => NextjsRuntimeFallback::NoneRequired,
Self::ClientSsr => NextjsRuntimeFallback::DeferUntilHydrated,
Self::ServerComponent | Self::NodeServer => NextjsRuntimeFallback::UseServerBridge,
Self::EdgeRuntime => NextjsRuntimeFallback::UseEdgeBridge,
}
}
#[must_use]
pub fn runtime_fallback_reason(self) -> &'static str {
match self.runtime_fallback() {
NextjsRuntimeFallback::NoneRequired => {
"runtime capability available: execute directly in hydrated client boundary"
}
NextjsRuntimeFallback::DeferUntilHydrated => {
"runtime unavailable during SSR client pass: defer initialization until hydration completes"
}
NextjsRuntimeFallback::UseServerBridge => {
"runtime unavailable in server boundary: route through serialized node/server bridge"
}
NextjsRuntimeFallback::UseEdgeBridge => {
"runtime unavailable in edge boundary: route through serialized edge bridge"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsCapability {
WasmRuntime,
ReactHooks,
DomAccess,
WebWorkers,
BrowserStorage,
AuthenticatedFetch,
ServerCookies,
RequestContext,
NodeApis,
StreamingSsr,
}
#[must_use]
pub fn is_capability_available(env: NextjsRenderEnvironment, cap: NextjsCapability) -> bool {
use NextjsCapability as C;
use NextjsRenderEnvironment as E;
matches!(
(env, cap),
(
E::ClientHydrated,
C::WasmRuntime | C::DomAccess | C::WebWorkers | C::BrowserStorage
) | (
E::ClientSsr | E::ClientHydrated,
C::ReactHooks | C::AuthenticatedFetch
) | (
E::ServerComponent | E::EdgeRuntime | E::NodeServer,
C::ServerCookies | C::RequestContext
) | (E::NodeServer, C::NodeApis)
| (E::ServerComponent | E::EdgeRuntime, C::StreamingSsr)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsAntiPattern {
WasmImportInServerComponent,
RuntimeCallDuringSsr,
RuntimeInitInRender,
HandlesSharingAcrossRoutes,
RuntimeInEdgeMiddleware,
BlockingHydration,
HandlesInServerActions,
}
impl NextjsAntiPattern {
#[must_use]
pub fn explanation(self) -> &'static str {
match self {
Self::WasmImportInServerComponent => {
"Server Components cannot import WASM modules. Use 'use client' directive."
}
Self::RuntimeCallDuringSsr => {
"WASM runtime is not available during SSR. Initialize in useEffect after hydration."
}
Self::RuntimeInitInRender => {
"Runtime initialization has side effects. Use useEffect, not the render function."
}
Self::HandlesSharingAcrossRoutes => {
"WASM handles are scoped to a provider instance. Each route segment needs its own provider."
}
Self::RuntimeInEdgeMiddleware => {
"Edge Runtime does not support WASM execution in this integration model."
}
Self::BlockingHydration => {
"Never block hydration on WASM init. Render a placeholder, then initialize async."
}
Self::HandlesInServerActions => {
"WasmHandleRef values are opaque client-side references. They cannot be serialized for server actions."
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsNavigationType {
SoftNavigation,
HardNavigation,
PopState,
}
impl NextjsNavigationType {
#[must_use]
pub fn runtime_survives(self) -> bool {
matches!(self, Self::SoftNavigation)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsBootstrapPhase {
ServerRendered,
Hydrating,
Hydrated,
RuntimeReady,
RuntimeFailed,
}
#[must_use]
pub fn is_valid_bootstrap_transition(from: NextjsBootstrapPhase, to: NextjsBootstrapPhase) -> bool {
if from == to {
return true;
}
matches!(
(from, to),
(
NextjsBootstrapPhase::ServerRendered,
NextjsBootstrapPhase::Hydrating
) | (
NextjsBootstrapPhase::Hydrating,
NextjsBootstrapPhase::Hydrated
| NextjsBootstrapPhase::RuntimeFailed
| NextjsBootstrapPhase::ServerRendered
) | (
NextjsBootstrapPhase::Hydrated,
NextjsBootstrapPhase::RuntimeReady
| NextjsBootstrapPhase::RuntimeFailed
| NextjsBootstrapPhase::ServerRendered
| NextjsBootstrapPhase::Hydrating
) | (
NextjsBootstrapPhase::RuntimeReady | NextjsBootstrapPhase::RuntimeFailed,
NextjsBootstrapPhase::Hydrating | NextjsBootstrapPhase::ServerRendered
)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[error("invalid bootstrap transition: {from:?} -> {to:?}")]
pub struct NextjsBootstrapTransitionError {
pub from: NextjsBootstrapPhase,
pub to: NextjsBootstrapPhase,
}
pub fn validate_bootstrap_transition(
from: NextjsBootstrapPhase,
to: NextjsBootstrapPhase,
) -> Result<(), NextjsBootstrapTransitionError> {
if is_valid_bootstrap_transition(from, to) {
Ok(())
} else {
Err(NextjsBootstrapTransitionError { from, to })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextjsComponentPlacement {
pub environment: NextjsRenderEnvironment,
pub route_segment: String,
pub inside_suspense: bool,
pub inside_error_boundary: bool,
pub layout_depth: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextjsIntegrationSnapshot {
pub bootstrap_phase: NextjsBootstrapPhase,
pub environment: NextjsRenderEnvironment,
pub route_segment: String,
pub active_provider_count: usize,
pub wasm_module_loaded: bool,
pub navigation_count: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NextjsBootstrapTrigger {
InitialState,
HydrationStarted,
HydrationCompleted,
RuntimeInitSucceeded,
RuntimeInitFailed,
RuntimeInitCancelled,
RetryAfterFailure,
Navigation,
HotReload,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextjsBootstrapTransitionRecord {
pub from: NextjsBootstrapPhase,
pub to: NextjsBootstrapPhase,
pub trigger: NextjsBootstrapTrigger,
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextjsBootstrapState {
phase: NextjsBootstrapPhase,
hydration_cycle_count: u32,
runtime_generation: u32,
navigation_count: u32,
hot_reload_count: u32,
last_failure: Option<String>,
transition_log: Vec<NextjsBootstrapTransitionRecord>,
}
impl NextjsBootstrapState {
#[must_use]
pub fn new() -> Self {
let initial_phase = NextjsBootstrapPhase::ServerRendered;
Self {
phase: initial_phase,
hydration_cycle_count: 0,
runtime_generation: 0,
navigation_count: 0,
hot_reload_count: 0,
last_failure: None,
transition_log: vec![NextjsBootstrapTransitionRecord {
from: initial_phase,
to: initial_phase,
trigger: NextjsBootstrapTrigger::InitialState,
detail: None,
}],
}
}
#[must_use]
pub fn phase(&self) -> NextjsBootstrapPhase {
self.phase
}
#[must_use]
pub fn hydration_cycle_count(&self) -> u32 {
self.hydration_cycle_count
}
#[must_use]
pub fn runtime_generation(&self) -> u32 {
self.runtime_generation
}
#[must_use]
pub fn navigation_count(&self) -> u32 {
self.navigation_count
}
#[must_use]
pub fn hot_reload_count(&self) -> u32 {
self.hot_reload_count
}
#[must_use]
pub fn last_failure(&self) -> Option<&str> {
self.last_failure.as_deref()
}
#[must_use]
pub fn transition_log(&self) -> &[NextjsBootstrapTransitionRecord] {
&self.transition_log
}
pub fn start_hydration(&mut self) -> Result<bool, NextjsBootstrapTransitionError> {
self.transition(
NextjsBootstrapPhase::Hydrating,
NextjsBootstrapTrigger::HydrationStarted,
None,
)
}
pub fn complete_hydration(&mut self) -> Result<bool, NextjsBootstrapTransitionError> {
self.transition(
NextjsBootstrapPhase::Hydrated,
NextjsBootstrapTrigger::HydrationCompleted,
None,
)
}
pub fn mark_runtime_ready(&mut self) -> Result<bool, NextjsBootstrapTransitionError> {
self.transition(
NextjsBootstrapPhase::RuntimeReady,
NextjsBootstrapTrigger::RuntimeInitSucceeded,
None,
)
}
pub fn mark_runtime_failed(
&mut self,
reason: impl Into<String>,
) -> Result<bool, NextjsBootstrapTransitionError> {
self.transition(
NextjsBootstrapPhase::RuntimeFailed,
NextjsBootstrapTrigger::RuntimeInitFailed,
Some(reason.into()),
)
}
pub fn mark_runtime_cancelled(
&mut self,
reason: impl Into<String>,
) -> Result<bool, NextjsBootstrapTransitionError> {
self.transition(
NextjsBootstrapPhase::RuntimeFailed,
NextjsBootstrapTrigger::RuntimeInitCancelled,
Some(reason.into()),
)
}
pub fn retry_after_failure(&mut self) -> Result<bool, NextjsBootstrapTransitionError> {
if self.phase != NextjsBootstrapPhase::RuntimeFailed {
return Err(NextjsBootstrapTransitionError {
from: self.phase,
to: NextjsBootstrapPhase::Hydrating,
});
}
self.transition(
NextjsBootstrapPhase::Hydrating,
NextjsBootstrapTrigger::RetryAfterFailure,
None,
)
}
pub fn on_navigation(
&mut self,
navigation: NextjsNavigationType,
) -> Result<bool, NextjsBootstrapTransitionError> {
self.navigation_count = self.navigation_count.saturating_add(1);
if navigation.runtime_survives() {
self.push_transition(
self.phase,
self.phase,
NextjsBootstrapTrigger::Navigation,
Some("soft_navigation".to_string()),
);
return Ok(false);
}
let detail = match navigation {
NextjsNavigationType::SoftNavigation => "soft_navigation",
NextjsNavigationType::HardNavigation => "hard_navigation",
NextjsNavigationType::PopState => "pop_state_navigation",
};
self.transition(
NextjsBootstrapPhase::ServerRendered,
NextjsBootstrapTrigger::Navigation,
Some(detail.to_string()),
)
}
pub fn on_hot_reload(&mut self) -> Result<bool, NextjsBootstrapTransitionError> {
self.hot_reload_count = self.hot_reload_count.saturating_add(1);
self.transition(
NextjsBootstrapPhase::Hydrating,
NextjsBootstrapTrigger::HotReload,
Some("fast_refresh".to_string()),
)
}
fn transition(
&mut self,
to: NextjsBootstrapPhase,
trigger: NextjsBootstrapTrigger,
detail: Option<String>,
) -> Result<bool, NextjsBootstrapTransitionError> {
let from = self.phase;
if from != to {
validate_bootstrap_transition(from, to)?;
self.phase = to;
}
if to == NextjsBootstrapPhase::Hydrating && from != NextjsBootstrapPhase::Hydrating {
self.hydration_cycle_count = self.hydration_cycle_count.saturating_add(1);
}
if matches!(
to,
NextjsBootstrapPhase::Hydrating | NextjsBootstrapPhase::ServerRendered
) && from != to
{
self.last_failure = None;
}
if to == NextjsBootstrapPhase::RuntimeReady && from != NextjsBootstrapPhase::RuntimeReady {
self.runtime_generation = self.runtime_generation.saturating_add(1);
self.last_failure = None;
} else if to == NextjsBootstrapPhase::RuntimeFailed {
self.last_failure.clone_from(&detail);
}
self.push_transition(from, to, trigger, detail);
Ok(from != to)
}
fn push_transition(
&mut self,
from: NextjsBootstrapPhase,
to: NextjsBootstrapPhase,
trigger: NextjsBootstrapTrigger,
detail: Option<String>,
) {
const MAX_TRANSITION_LOG: usize = 256;
if self.transition_log.len() >= MAX_TRANSITION_LOG {
self.transition_log.drain(..MAX_TRANSITION_LOG / 2);
}
self.transition_log.push(NextjsBootstrapTransitionRecord {
from,
to,
trigger,
detail,
});
}
}
impl Default for NextjsBootstrapState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SuspenseBoundaryState {
Pending,
Resolved,
ErrorRecoverable,
ErrorFatal,
Cancelled,
}
#[must_use]
pub fn outcome_to_suspense_state(outcome: &WasmAbiOutcomeEnvelope) -> SuspenseBoundaryState {
match outcome {
WasmAbiOutcomeEnvelope::Ok { .. } => SuspenseBoundaryState::Resolved,
WasmAbiOutcomeEnvelope::Err { failure } => match failure.recoverability {
WasmAbiRecoverability::Permanent => SuspenseBoundaryState::ErrorFatal,
WasmAbiRecoverability::Transient | WasmAbiRecoverability::Unknown => {
SuspenseBoundaryState::ErrorRecoverable
}
},
WasmAbiOutcomeEnvelope::Cancelled { .. } => SuspenseBoundaryState::Cancelled,
WasmAbiOutcomeEnvelope::Panicked { .. } => SuspenseBoundaryState::ErrorFatal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorBoundaryAction {
None,
ShowWithRetry,
ShowFatal,
Reset,
}
#[must_use]
pub fn outcome_to_error_boundary_action(outcome: &WasmAbiOutcomeEnvelope) -> ErrorBoundaryAction {
match outcome {
WasmAbiOutcomeEnvelope::Ok { .. } | WasmAbiOutcomeEnvelope::Cancelled { .. } => {
ErrorBoundaryAction::None
}
WasmAbiOutcomeEnvelope::Err { failure } => match failure.recoverability {
WasmAbiRecoverability::Permanent => ErrorBoundaryAction::ShowFatal,
WasmAbiRecoverability::Transient | WasmAbiRecoverability::Unknown => {
ErrorBoundaryAction::ShowWithRetry
}
},
WasmAbiOutcomeEnvelope::Panicked { .. } => ErrorBoundaryAction::ShowFatal,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionTaskState {
Pending,
Committed,
Reverted,
Cancelled,
}
#[must_use]
pub fn outcome_to_transition_state(outcome: &WasmAbiOutcomeEnvelope) -> TransitionTaskState {
match outcome {
WasmAbiOutcomeEnvelope::Ok { .. } => TransitionTaskState::Committed,
WasmAbiOutcomeEnvelope::Err { .. } | WasmAbiOutcomeEnvelope::Panicked { .. } => {
TransitionTaskState::Reverted
}
WasmAbiOutcomeEnvelope::Cancelled { .. } => TransitionTaskState::Cancelled,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SuspenseTaskConfig {
pub label: String,
pub is_transition: bool,
pub show_fallback_on_retry: bool,
pub max_retries: u32,
}
impl Default for SuspenseTaskConfig {
fn default() -> Self {
Self {
label: "suspense-task".to_string(),
is_transition: false,
show_fallback_on_retry: true,
max_retries: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SuspenseTaskSnapshot {
pub config: SuspenseTaskConfig,
pub boundary_state: SuspenseBoundaryState,
pub error_action: ErrorBoundaryAction,
pub transition_state: Option<TransitionTaskState>,
pub task_handle: Option<WasmHandleRef>,
pub retry_count: u32,
pub is_retrying: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProgressiveLoadSlot {
pub label: String,
pub required: bool,
pub state: SuspenseBoundaryState,
pub task_handle: Option<WasmHandleRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProgressiveLoadSnapshot {
pub slots: Vec<ProgressiveLoadSlot>,
pub overall_state: SuspenseBoundaryState,
}
impl ProgressiveLoadSnapshot {
#[must_use]
pub fn compute_overall_state(slots: &[ProgressiveLoadSlot]) -> SuspenseBoundaryState {
let mut has_pending_required = false;
let mut has_fatal = false;
let mut has_recoverable_error = false;
for slot in slots {
if !slot.required {
continue;
}
match slot.state {
SuspenseBoundaryState::Pending => has_pending_required = true,
SuspenseBoundaryState::ErrorFatal => has_fatal = true,
SuspenseBoundaryState::ErrorRecoverable => has_recoverable_error = true,
SuspenseBoundaryState::Resolved | SuspenseBoundaryState::Cancelled => {}
}
}
if has_fatal {
SuspenseBoundaryState::ErrorFatal
} else if has_recoverable_error {
SuspenseBoundaryState::ErrorRecoverable
} else if has_pending_required {
SuspenseBoundaryState::Pending
} else {
SuspenseBoundaryState::Resolved
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SuspenseDiagnosticEvent {
pub label: String,
pub from_state: SuspenseBoundaryState,
pub to_state: SuspenseBoundaryState,
pub is_transition: bool,
pub error_action: ErrorBoundaryAction,
pub task_handle: Option<WasmHandleRef>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{CancelKind, CancelReason, PanicPayload, RegionId, Time};
fn close_handle_for_release(table: &mut WasmHandleTable, handle: &WasmHandleRef) {
match table.get(handle).unwrap().state {
WasmBoundaryState::Unbound => {
table.transition(handle, WasmBoundaryState::Bound).unwrap();
table.transition(handle, WasmBoundaryState::Closed).unwrap();
}
WasmBoundaryState::Bound
| WasmBoundaryState::Active
| WasmBoundaryState::Cancelling
| WasmBoundaryState::Draining => {
table.transition(handle, WasmBoundaryState::Closed).unwrap();
}
WasmBoundaryState::Closed => {}
}
}
#[test]
fn abi_compatibility_rules_enforced() {
let exact = classify_wasm_abi_compatibility(
WasmAbiVersion { major: 1, minor: 2 },
WasmAbiVersion { major: 1, minor: 2 },
);
assert_eq!(exact, WasmAbiCompatibilityDecision::Exact);
assert!(exact.is_compatible());
let backward = classify_wasm_abi_compatibility(
WasmAbiVersion { major: 1, minor: 2 },
WasmAbiVersion { major: 1, minor: 5 },
);
assert!(matches!(
backward,
WasmAbiCompatibilityDecision::BackwardCompatible {
producer_minor: 2,
consumer_minor: 5
}
));
assert!(backward.is_compatible());
let old_consumer = classify_wasm_abi_compatibility(
WasmAbiVersion { major: 1, minor: 3 },
WasmAbiVersion { major: 1, minor: 2 },
);
assert!(matches!(
old_consumer,
WasmAbiCompatibilityDecision::ConsumerTooOld {
producer_minor: 3,
consumer_minor: 2
}
));
assert!(!old_consumer.is_compatible());
let major_mismatch = classify_wasm_abi_compatibility(
WasmAbiVersion { major: 1, minor: 0 },
WasmAbiVersion { major: 2, minor: 0 },
);
assert!(matches!(
major_mismatch,
WasmAbiCompatibilityDecision::MajorMismatch {
producer_major: 1,
consumer_major: 2
}
));
assert!(!major_mismatch.is_compatible());
}
#[test]
fn change_class_maps_to_required_version_bump() {
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::AdditiveField),
WasmAbiVersionBump::Minor
);
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::AdditiveSymbol),
WasmAbiVersionBump::Minor
);
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::BehavioralRelaxation),
WasmAbiVersionBump::Minor
);
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::BehavioralTightening),
WasmAbiVersionBump::Major
);
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::SymbolRemoval),
WasmAbiVersionBump::Major
);
assert_eq!(
required_wasm_abi_bump(WasmAbiChangeClass::ValueEncodingChange),
WasmAbiVersionBump::Major
);
}
#[test]
fn signature_fingerprint_matches_expected_v1() {
let fingerprint = wasm_abi_signature_fingerprint(&WASM_ABI_SIGNATURES_V1);
assert_eq!(
fingerprint, WASM_ABI_SIGNATURE_FINGERPRINT_V1,
"ABI signature drift detected; update version policy and migration notes first"
);
}
#[test]
fn cancellation_payload_maps_core_reason_fields() {
let reason = CancelReason::with_origin(
CancelKind::Timeout,
RegionId::new_for_test(3, 7),
Time::from_nanos(42),
)
.with_task(crate::types::TaskId::new_for_test(4, 1))
.with_message("deadline exceeded");
let encoded = WasmAbiCancellation::from_reason(&reason, CancelPhase::Cancelling);
assert_eq!(encoded.kind, "timeout");
assert_eq!(encoded.phase, "cancelling");
assert_eq!(encoded.timestamp_nanos, 42);
assert_eq!(encoded.message.as_deref(), Some("deadline exceeded"));
assert_eq!(encoded.origin_region, "R3");
assert_eq!(encoded.origin_task.as_deref(), Some("T4"));
}
#[test]
fn abort_signal_event_propagates_to_runtime_when_configured() {
let snapshot = WasmAbortInteropSnapshot {
mode: WasmAbortPropagationMode::AbortSignalToRuntime,
boundary_state: WasmBoundaryState::Active,
abort_signal_aborted: false,
};
let update = apply_abort_signal_event(snapshot);
assert_eq!(update.next_boundary_state, WasmBoundaryState::Cancelling);
assert!(update.abort_signal_aborted);
assert!(update.propagated_to_runtime);
assert!(!update.propagated_to_abort_signal);
let repeated = apply_abort_signal_event(WasmAbortInteropSnapshot {
mode: snapshot.mode,
boundary_state: update.next_boundary_state,
abort_signal_aborted: update.abort_signal_aborted,
});
assert_eq!(repeated.next_boundary_state, WasmBoundaryState::Cancelling);
assert!(repeated.abort_signal_aborted);
assert!(!repeated.propagated_to_runtime);
assert!(!repeated.propagated_to_abort_signal);
}
#[test]
fn runtime_cancel_phase_event_maps_to_abort_signal_and_state() {
let requested = apply_runtime_cancel_phase_event(
WasmAbortInteropSnapshot {
mode: WasmAbortPropagationMode::RuntimeToAbortSignal,
boundary_state: WasmBoundaryState::Active,
abort_signal_aborted: false,
},
CancelPhase::Requested,
);
assert_eq!(requested.next_boundary_state, WasmBoundaryState::Cancelling);
assert!(requested.abort_signal_aborted);
assert!(requested.propagated_to_abort_signal);
assert!(!requested.propagated_to_runtime);
let finalizing = apply_runtime_cancel_phase_event(
WasmAbortInteropSnapshot {
mode: WasmAbortPropagationMode::RuntimeToAbortSignal,
boundary_state: requested.next_boundary_state,
abort_signal_aborted: requested.abort_signal_aborted,
},
CancelPhase::Finalizing,
);
assert_eq!(finalizing.next_boundary_state, WasmBoundaryState::Draining);
assert!(finalizing.abort_signal_aborted);
assert!(!finalizing.propagated_to_abort_signal);
let completed = apply_runtime_cancel_phase_event(
WasmAbortInteropSnapshot {
mode: WasmAbortPropagationMode::RuntimeToAbortSignal,
boundary_state: finalizing.next_boundary_state,
abort_signal_aborted: finalizing.abort_signal_aborted,
},
CancelPhase::Completed,
);
assert_eq!(completed.next_boundary_state, WasmBoundaryState::Closed);
assert!(completed.abort_signal_aborted);
}
#[test]
fn bidirectional_mode_keeps_already_aborted_signal_idempotent() {
let update = apply_abort_signal_event(WasmAbortInteropSnapshot {
mode: WasmAbortPropagationMode::Bidirectional,
boundary_state: WasmBoundaryState::Active,
abort_signal_aborted: true,
});
assert_eq!(update.next_boundary_state, WasmBoundaryState::Active);
assert!(update.abort_signal_aborted);
assert!(!update.propagated_to_runtime);
}
#[test]
fn outcome_envelope_serialization_round_trip() {
let handle = WasmHandleRef {
kind: WasmHandleKind::Task,
slot: 11,
generation: 2,
};
let ok = WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Handle(handle),
};
let ok_json = serde_json::to_string(&ok).expect("serialize ok");
let ok_back: WasmAbiOutcomeEnvelope =
serde_json::from_str(&ok_json).expect("deserialize ok");
assert_eq!(ok, ok_back);
let err = WasmAbiOutcomeEnvelope::Err {
failure: WasmAbiFailure {
code: WasmAbiErrorCode::CapabilityDenied,
recoverability: WasmAbiRecoverability::Permanent,
message: "missing fetch capability".to_string(),
},
};
let err_json = serde_json::to_string(&err).expect("serialize err");
let err_back: WasmAbiOutcomeEnvelope =
serde_json::from_str(&err_json).expect("deserialize err");
assert_eq!(err, err_back);
}
#[test]
fn from_outcome_maps_cancel_and_panic_variants() {
let cancel_reason = CancelReason::with_origin(
CancelKind::ParentCancelled,
RegionId::new_for_test(9, 1),
Time::from_nanos(9_000),
);
let cancelled = WasmAbiOutcomeEnvelope::from_outcome(Outcome::cancelled(cancel_reason));
assert!(matches!(
cancelled,
WasmAbiOutcomeEnvelope::Cancelled {
cancellation: WasmAbiCancellation {
kind,
phase,
..
}
} if kind == "parentcancelled" && phase == "completed"
));
let panicked = WasmAbiOutcomeEnvelope::from_outcome(Outcome::Panicked(PanicPayload::new(
"boundary panic",
)));
assert_eq!(
panicked,
WasmAbiOutcomeEnvelope::Panicked {
message: "boundary panic".to_string(),
}
);
}
#[test]
fn boundary_transition_validator_accepts_and_rejects_expected_paths() {
assert!(
validate_wasm_boundary_transition(WasmBoundaryState::Unbound, WasmBoundaryState::Bound)
.is_ok()
);
assert!(
validate_wasm_boundary_transition(WasmBoundaryState::Bound, WasmBoundaryState::Active)
.is_ok()
);
assert!(
validate_wasm_boundary_transition(
WasmBoundaryState::Active,
WasmBoundaryState::Cancelling
)
.is_ok()
);
assert!(
validate_wasm_boundary_transition(
WasmBoundaryState::Cancelling,
WasmBoundaryState::Draining
)
.is_ok()
);
assert!(
validate_wasm_boundary_transition(
WasmBoundaryState::Draining,
WasmBoundaryState::Closed
)
.is_ok()
);
let invalid =
validate_wasm_boundary_transition(WasmBoundaryState::Closed, WasmBoundaryState::Active);
assert!(matches!(
invalid,
Err(WasmBoundaryTransitionError::Invalid {
from: WasmBoundaryState::Closed,
to: WasmBoundaryState::Active
})
));
}
#[test]
fn boundary_event_log_fields_include_contract_keys() {
let event = WasmAbiBoundaryEvent {
abi_version: WasmAbiVersion::CURRENT,
symbol: WasmAbiSymbol::FetchRequest,
payload_shape: WasmAbiPayloadShape::FetchRequestV1,
state_from: WasmBoundaryState::Active,
state_to: WasmBoundaryState::Cancelling,
compatibility: WasmAbiCompatibilityDecision::Exact,
};
let fields = event.as_log_fields();
assert_eq!(fields.get("abi_version"), Some(&"1.0".to_string()));
assert_eq!(fields.get("symbol"), Some(&"fetch_request".to_string()));
assert!(fields.contains_key("payload_shape"));
assert!(fields.contains_key("state_from"));
assert!(fields.contains_key("state_to"));
assert!(fields.contains_key("compatibility"));
assert_eq!(fields.get("compatibility"), Some(&"exact".to_string()));
assert_eq!(
fields.get("compatibility_decision"),
Some(&"exact".to_string())
);
assert_eq!(
fields.get("compatibility_compatible"),
Some(&"true".to_string())
);
assert_eq!(
fields.get("compatibility_producer_major"),
Some(&"1".to_string())
);
assert_eq!(
fields.get("compatibility_consumer_major"),
Some(&"1".to_string())
);
assert_eq!(
fields.get("compatibility_producer_minor"),
Some(&"0".to_string())
);
assert_eq!(
fields.get("compatibility_consumer_minor"),
Some(&"0".to_string())
);
assert_eq!(
fields.get("payload_shape"),
Some(&"fetch_request_v1".to_string())
);
assert_eq!(fields.get("state_from"), Some(&"active".to_string()));
assert_eq!(fields.get("state_to"), Some(&"cancelling".to_string()));
}
#[test]
fn major_mismatch_log_fields_include_major_only_details() {
let event = WasmAbiBoundaryEvent {
abi_version: WasmAbiVersion::CURRENT,
symbol: WasmAbiSymbol::RuntimeCreate,
payload_shape: WasmAbiPayloadShape::Empty,
state_from: WasmBoundaryState::Unbound,
state_to: WasmBoundaryState::Bound,
compatibility: WasmAbiCompatibilityDecision::MajorMismatch {
producer_major: 1,
consumer_major: 2,
},
};
let fields = event.as_log_fields();
assert_eq!(
fields.get("compatibility_decision"),
Some(&"major_mismatch".to_string())
);
assert_eq!(
fields.get("compatibility_compatible"),
Some(&"false".to_string())
);
assert_eq!(
fields.get("compatibility_producer_major"),
Some(&"1".to_string())
);
assert_eq!(
fields.get("compatibility_consumer_major"),
Some(&"2".to_string())
);
assert!(!fields.contains_key("compatibility_producer_minor"));
assert!(!fields.contains_key("compatibility_consumer_minor"));
}
#[test]
fn handle_table_allocate_and_get() {
let mut table = WasmHandleTable::new();
assert_eq!(table.live_count(), 0);
let h1 = table.allocate(WasmHandleKind::Runtime);
assert_eq!(h1.slot, 0);
assert_eq!(h1.generation, 0);
assert_eq!(h1.kind, WasmHandleKind::Runtime);
assert_eq!(table.live_count(), 1);
let entry = table.get(&h1).unwrap();
assert_eq!(entry.state, WasmBoundaryState::Unbound);
assert_eq!(entry.ownership, WasmHandleOwnership::WasmOwned);
assert!(!entry.pinned);
let h2 = table.allocate(WasmHandleKind::Task);
assert_eq!(h2.slot, 1);
assert_eq!(table.live_count(), 2);
}
#[test]
fn handle_table_full_lifecycle() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Region);
table.transition(&h, WasmBoundaryState::Bound).unwrap();
assert_eq!(table.get(&h).unwrap().state, WasmBoundaryState::Bound);
table.transition(&h, WasmBoundaryState::Active).unwrap();
assert_eq!(table.get(&h).unwrap().state, WasmBoundaryState::Active);
table.transition(&h, WasmBoundaryState::Cancelling).unwrap();
assert_eq!(table.get(&h).unwrap().state, WasmBoundaryState::Cancelling);
table.transition(&h, WasmBoundaryState::Draining).unwrap();
assert_eq!(table.get(&h).unwrap().state, WasmBoundaryState::Draining);
table.transition(&h, WasmBoundaryState::Closed).unwrap();
assert_eq!(table.get(&h).unwrap().state, WasmBoundaryState::Closed);
table.release(&h).unwrap();
assert_eq!(table.live_count(), 0);
}
#[test]
fn handle_table_slot_recycling_with_generation_bump() {
let mut table = WasmHandleTable::new();
let h1 = table.allocate(WasmHandleKind::Task);
assert_eq!(h1.slot, 0);
assert_eq!(h1.generation, 0);
close_handle_for_release(&mut table, &h1);
table.release(&h1).unwrap();
let h2 = table.allocate(WasmHandleKind::Region);
assert_eq!(h2.slot, 0);
assert_eq!(h2.generation, 1);
let err = table.get(&h1).unwrap_err();
assert!(matches!(
err,
WasmHandleError::StaleGeneration {
slot: 0,
expected: 1,
actual: 0,
}
));
assert!(table.get(&h2).is_ok());
}
#[test]
fn handle_table_stale_handle_rejected() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::CancelToken);
close_handle_for_release(&mut table, &h);
table.release(&h).unwrap();
let err = table.get(&h).unwrap_err();
assert!(matches!(err, WasmHandleError::StaleGeneration { .. }));
}
#[test]
fn handle_table_out_of_range() {
let table = WasmHandleTable::new();
let fake = WasmHandleRef {
kind: WasmHandleKind::Runtime,
slot: 999,
generation: 0,
};
let err = table.get(&fake).unwrap_err();
assert!(matches!(
err,
WasmHandleError::SlotOutOfRange {
slot: 999,
table_size: 0,
}
));
}
#[test]
fn handle_table_pin_unpin() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Task);
table.pin(&h).unwrap();
assert!(table.get(&h).unwrap().pinned);
table.pin(&h).unwrap();
assert!(table.get(&h).unwrap().pinned);
let err = table.release(&h).unwrap_err();
assert!(matches!(err, WasmHandleError::ReleasePinned { slot: 0 }));
table.unpin(&h).unwrap();
assert!(!table.get(&h).unwrap().pinned);
close_handle_for_release(&mut table, &h);
table.release(&h).unwrap();
assert_eq!(table.live_count(), 0);
}
#[test]
fn handle_table_unpin_not_pinned_is_error() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Runtime);
let err = table.unpin(&h).unwrap_err();
assert!(matches!(err, WasmHandleError::NotPinned { slot: 0 }));
}
#[test]
fn handle_table_transfer_to_js() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::FetchRequest);
table.transfer_to_js(&h).unwrap();
assert_eq!(
table.get(&h).unwrap().ownership,
WasmHandleOwnership::TransferredToJs
);
let err = table.transfer_to_js(&h).unwrap_err();
assert!(matches!(err, WasmHandleError::InvalidTransfer { .. }));
}
#[test]
fn handle_table_detect_leaks() {
let mut table = WasmHandleTable::new();
let h1 = table.allocate(WasmHandleKind::Task);
let h2 = table.allocate(WasmHandleKind::Region);
let h3 = table.allocate(WasmHandleKind::Runtime);
table.transition(&h1, WasmBoundaryState::Bound).unwrap();
table.transition(&h1, WasmBoundaryState::Closed).unwrap();
table.transition(&h2, WasmBoundaryState::Bound).unwrap();
table.transition(&h2, WasmBoundaryState::Active).unwrap();
close_handle_for_release(&mut table, &h3);
table.release(&h3).unwrap();
let leaks = table.detect_leaks();
assert_eq!(leaks.len(), 1);
assert_eq!(leaks[0], h1);
}
#[test]
fn handle_table_memory_report() {
let mut table = WasmHandleTable::with_capacity(8);
let h1 = table.allocate(WasmHandleKind::Runtime);
let h2 = table.allocate(WasmHandleKind::Task);
let _h3 = table.allocate(WasmHandleKind::Task);
table.transition(&h1, WasmBoundaryState::Bound).unwrap();
table.transition(&h1, WasmBoundaryState::Active).unwrap();
table.pin(&h2).unwrap();
let report = table.memory_report();
assert_eq!(report.live_handles, 3);
assert_eq!(report.pinned_count, 1);
assert_eq!(report.by_kind.get("task"), Some(&2));
assert_eq!(report.by_kind.get("runtime"), Some(&1));
assert_eq!(report.by_state.get("unbound"), Some(&2));
assert_eq!(report.by_state.get("active"), Some(&1));
}
#[test]
fn handle_table_release_already_released() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Task);
close_handle_for_release(&mut table, &h);
table.release(&h).unwrap();
let err = table.release(&h).unwrap_err();
assert!(matches!(err, WasmHandleError::StaleGeneration { .. }));
}
#[test]
fn handle_table_release_requires_closed_and_quiescent_state() {
let mut table = WasmHandleTable::new();
let root = table.allocate(WasmHandleKind::Runtime);
let child = table.allocate_with_parent(WasmHandleKind::Task, Some(root));
let err = table.release(&root).unwrap_err();
assert_eq!(
err,
WasmHandleError::ReleaseBeforeClosed {
slot: root.slot,
state: WasmBoundaryState::Unbound,
}
);
assert_eq!(table.live_count(), 2);
close_handle_for_release(&mut table, &root);
let err = table.release(&root).unwrap_err();
assert_eq!(
err,
WasmHandleError::ReleaseWithLiveDescendants {
slot: root.slot,
live_descendants: 1,
}
);
assert_eq!(table.live_count(), 2);
close_handle_for_release(&mut table, &child);
table.release(&child).unwrap();
table.release(&root).unwrap();
assert_eq!(table.live_count(), 0);
}
#[test]
fn handle_table_descendants_postorder_rejects_parent_cycles() {
let mut table = WasmHandleTable::new();
let root = table.allocate(WasmHandleKind::Runtime);
let child = table.allocate_with_parent(WasmHandleKind::Region, Some(root));
table.get_mut(&root).unwrap().parent = Some(child);
let err = table.descendants_postorder(&root).unwrap_err();
assert_eq!(
err,
WasmHandleError::OwnershipCycle {
slot: root.slot,
parent_slot: child.slot,
}
);
}
#[test]
fn buffer_transfer_mode_copy_is_default() {
assert!(WasmBufferTransferMode::Copy.is_copy());
assert!(!WasmBufferTransferMode::Transfer.is_copy());
}
#[test]
fn buffer_transfer_serialization_round_trip() {
let transfer = WasmBufferTransfer {
source_handle: WasmHandleRef {
kind: WasmHandleKind::FetchRequest,
slot: 5,
generation: 2,
},
byte_length: 1024,
mode: WasmBufferTransferMode::Transfer,
};
let json = serde_json::to_string(&transfer).unwrap();
let back: WasmBufferTransfer = serde_json::from_str(&json).unwrap();
assert_eq!(transfer, back);
}
#[test]
fn handle_ownership_serialization_round_trip() {
for ownership in [
WasmHandleOwnership::WasmOwned,
WasmHandleOwnership::TransferredToJs,
WasmHandleOwnership::Released,
] {
let json = serde_json::to_string(&ownership).unwrap();
let back: WasmHandleOwnership = serde_json::from_str(&json).unwrap();
assert_eq!(ownership, back);
}
}
#[test]
fn handle_lifecycle_event_captures_transitions() {
let event = WasmHandleLifecycleEvent {
handle: WasmHandleRef {
kind: WasmHandleKind::Task,
slot: 3,
generation: 0,
},
event: WasmHandleEventKind::StateTransition,
ownership_before: WasmHandleOwnership::WasmOwned,
ownership_after: WasmHandleOwnership::WasmOwned,
state_before: WasmBoundaryState::Active,
state_after: WasmBoundaryState::Cancelling,
};
let json = serde_json::to_string(&event).unwrap();
let back: WasmHandleLifecycleEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, back);
}
#[test]
fn handle_table_cancellation_and_release_flow() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Task);
table.transition(&h, WasmBoundaryState::Bound).unwrap();
table.transition(&h, WasmBoundaryState::Active).unwrap();
table.pin(&h).unwrap();
table.transition(&h, WasmBoundaryState::Cancelling).unwrap();
table.transition(&h, WasmBoundaryState::Draining).unwrap();
table.transition(&h, WasmBoundaryState::Closed).unwrap();
assert!(table.release(&h).is_err());
table.unpin(&h).unwrap();
table.release(&h).unwrap();
assert_eq!(table.live_count(), 0);
assert!(table.detect_leaks().is_empty());
}
#[test]
fn handle_table_with_capacity_preallocates() {
let table = WasmHandleTable::with_capacity(16);
assert_eq!(table.live_count(), 0);
assert_eq!(table.capacity(), 0); }
#[test]
fn dispatcher_runtime_create_and_close_lifecycle() {
let mut d = WasmExportDispatcher::new();
assert_eq!(d.dispatch_count(), 0);
let rt = d.runtime_create(None).unwrap();
assert_eq!(rt.kind, WasmHandleKind::Runtime);
assert_eq!(d.dispatch_count(), 1);
assert_eq!(d.handles().live_count(), 1);
let outcome = d.runtime_close(&rt, None).unwrap();
assert!(matches!(outcome, WasmAbiOutcomeEnvelope::Ok { .. }));
assert_eq!(d.dispatch_count(), 2);
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn dispatcher_scope_enter_and_close() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("test-scope".to_string()),
},
None,
)
.unwrap();
assert_eq!(scope.kind, WasmHandleKind::Region);
assert_eq!(d.handles().live_count(), 2);
let outcome = d.scope_close(&scope, None).unwrap();
assert!(matches!(outcome, WasmAbiOutcomeEnvelope::Ok { .. }));
assert_eq!(d.handles().live_count(), 1);
d.runtime_close(&rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn dispatcher_runtime_close_releases_descendants() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("runtime-close".to_string()),
},
None,
)
.unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: Some("worker".to_string()),
cancel_kind: Some("user".to_string()),
},
None,
)
.unwrap();
let fetch = d
.fetch_request(
&WasmFetchRequest {
scope,
url: "https://example.com/data".to_string(),
method: "GET".to_string(),
body: None,
},
None,
)
.unwrap();
let outcome = d.runtime_close(&rt, None).unwrap();
assert!(matches!(outcome, WasmAbiOutcomeEnvelope::Ok { .. }));
assert_eq!(d.handles().live_count(), 0);
assert!(d.handles().get(&scope).is_err());
assert!(d.handles().get(&task).is_err());
assert!(d.handles().get(&fetch).is_err());
assert!(d.handles().detect_leaks().is_empty());
}
#[test]
fn dispatcher_scope_close_releases_nested_descendants() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let outer = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("outer".to_string()),
},
None,
)
.unwrap();
let inner = d
.scope_enter(
&WasmScopeEnterRequest {
parent: outer,
label: Some("inner".to_string()),
},
None,
)
.unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: inner,
label: Some("nested".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
let outcome = d.scope_close(&outer, None).unwrap();
assert!(matches!(outcome, WasmAbiOutcomeEnvelope::Ok { .. }));
assert!(d.handles().get(&inner).is_err());
assert!(d.handles().get(&task).is_err());
assert_eq!(d.handles().live_count(), 1);
d.runtime_close(&rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn dispatcher_scope_close_rejects_ownership_cycles_in_handle_graph() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let outer = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("outer".to_string()),
},
None,
)
.unwrap();
let inner = d
.scope_enter(
&WasmScopeEnterRequest {
parent: outer,
label: Some("inner".to_string()),
},
None,
)
.unwrap();
d.handles_mut().get_mut(&outer).unwrap().parent = Some(inner);
let err = d.scope_close(&outer, None).unwrap_err();
assert_eq!(
err,
WasmDispatchError::Handle(WasmHandleError::OwnershipCycle {
slot: outer.slot,
parent_slot: inner.slot,
})
);
assert_eq!(d.handles().live_count(), 3);
}
#[test]
fn dispatcher_task_spawn_join_lifecycle() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: None,
},
None,
)
.unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: Some("worker".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
assert_eq!(task.kind, WasmHandleKind::Task);
assert!(d.handles().get(&task).unwrap().pinned);
let result = d
.task_join(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42),
},
None,
)
.unwrap();
assert!(matches!(
result,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42)
}
));
d.scope_close(&scope, None).unwrap();
d.runtime_close(&rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn dispatcher_task_cancel_flow() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
let cancel_result = d
.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "user".to_string(),
message: Some("user requested".to_string()),
},
None,
)
.unwrap();
assert!(matches!(cancel_result, WasmAbiOutcomeEnvelope::Ok { .. }));
assert_eq!(
d.handles().get(&task).unwrap().state,
WasmBoundaryState::Cancelling
);
let join_result = d
.task_join(
&task,
WasmAbiOutcomeEnvelope::Cancelled {
cancellation: WasmAbiCancellation {
kind: "user".to_string(),
phase: "completed".to_string(),
origin_region: "R0".to_string(),
origin_task: None,
timestamp_nanos: 0,
message: Some("user requested".to_string()),
truncated: false,
},
},
None,
)
.unwrap();
assert!(matches!(
join_result,
WasmAbiOutcomeEnvelope::Cancelled { .. }
));
assert_eq!(d.handles().live_count(), 1);
d.runtime_close(&rt, None).unwrap();
}
#[test]
fn dispatcher_abi_incompatible_rejected() {
let mut d = WasmExportDispatcher::new();
let bad_version = WasmAbiVersion {
major: 99,
minor: 0,
};
let err = d.runtime_create(Some(bad_version)).unwrap_err();
assert!(matches!(err, WasmDispatchError::Incompatible { .. }));
let failure = err.to_failure();
assert_eq!(failure.code, WasmAbiErrorCode::CompatibilityRejected);
assert_eq!(failure.recoverability, WasmAbiRecoverability::Permanent);
}
#[test]
fn dispatcher_stale_handle_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
d.runtime_close(&rt, None).unwrap();
let err = d.runtime_close(&rt, None).unwrap_err();
assert!(matches!(err, WasmDispatchError::Handle(_)));
}
#[test]
fn dispatcher_scope_enter_requires_active_parent() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
d.runtime_close(&rt, None).unwrap();
let err = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: None,
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::Handle(_)));
}
#[test]
fn dispatcher_task_spawn_wrong_handle_kind_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
let err = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: task,
label: None,
cancel_kind: None,
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::InvalidRequest { .. }));
}
#[test]
fn dispatcher_scope_enter_wrong_parent_kind_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: Some("worker".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
let err = d
.scope_enter(
&WasmScopeEnterRequest {
parent: task,
label: Some("illegal-child".to_string()),
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::InvalidRequest { .. }));
}
#[test]
fn dispatcher_fetch_request_wrong_scope_kind_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: Some("worker".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
let err = d
.fetch_request(
&WasmFetchRequest {
scope: task,
url: "https://example.com/data".to_string(),
method: "GET".to_string(),
body: None,
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::InvalidRequest { .. }));
}
#[test]
fn dispatcher_cancel_non_active_task_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
d.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "user".to_string(),
message: None,
},
None,
)
.unwrap();
let err = d
.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "user".to_string(),
message: None,
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::InvalidState { .. }));
}
#[test]
fn dispatcher_fetch_request_and_complete() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: None,
},
None,
)
.unwrap();
let fetch = d
.fetch_request(
&WasmFetchRequest {
scope,
url: "https://example.com/api".to_string(),
method: "GET".to_string(),
body: None,
},
None,
)
.unwrap();
assert_eq!(fetch.kind, WasmHandleKind::FetchRequest);
assert!(d.handles().get(&fetch).unwrap().pinned);
let result = d
.fetch_complete(
&fetch,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::String("response body".to_string()),
},
)
.unwrap();
assert!(matches!(result, WasmAbiOutcomeEnvelope::Ok { .. }));
assert!(d.handles().get(&fetch).is_err());
}
#[test]
fn dispatcher_fetch_empty_url_rejected() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let err = d
.fetch_request(
&WasmFetchRequest {
scope: rt,
url: String::new(),
method: "GET".to_string(),
body: None,
},
None,
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::InvalidRequest { .. }));
}
#[test]
fn dispatcher_event_log_records_all_symbol_calls() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: None,
},
None,
)
.unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
d.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "timeout".to_string(),
message: None,
},
None,
)
.unwrap();
d.task_join(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
},
None,
)
.unwrap();
d.scope_close(&scope, None).unwrap();
d.runtime_close(&rt, None).unwrap();
let events = d.event_log().events();
assert_eq!(events.len(), 7);
assert_eq!(events[0].symbol, WasmAbiSymbol::RuntimeCreate);
assert_eq!(events[1].symbol, WasmAbiSymbol::ScopeEnter);
assert_eq!(events[2].symbol, WasmAbiSymbol::TaskSpawn);
assert_eq!(events[3].symbol, WasmAbiSymbol::TaskCancel);
assert_eq!(events[4].symbol, WasmAbiSymbol::TaskJoin);
assert_eq!(events[5].symbol, WasmAbiSymbol::ScopeClose);
assert_eq!(events[6].symbol, WasmAbiSymbol::RuntimeClose);
}
#[test]
fn dispatcher_event_log_drain_clears() {
let mut d = WasmExportDispatcher::new();
d.runtime_create(None).unwrap();
assert_eq!(d.event_log().len(), 1);
let drained = d.event_log_mut().drain();
assert_eq!(drained.len(), 1);
assert!(d.event_log().is_empty());
}
#[test]
fn dispatcher_dispatch_count_increments_on_errors() {
let mut d = WasmExportDispatcher::new();
let _ = d.runtime_create(Some(WasmAbiVersion {
major: 99,
minor: 0,
}));
assert_eq!(d.dispatch_count(), 1);
}
#[test]
fn dispatcher_abort_signal_propagation() {
let mut d =
WasmExportDispatcher::new().with_abort_mode(WasmAbortPropagationMode::Bidirectional);
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
let update = d.apply_abort(&task).unwrap();
assert!(update.propagated_to_runtime);
assert_eq!(
d.handles().get(&task).unwrap().state,
WasmBoundaryState::Cancelling
);
}
#[test]
fn dispatcher_full_multi_task_lifecycle() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let scope = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("multi-task".to_string()),
},
None,
)
.unwrap();
let t1 = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: Some("t1".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
let t2 = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: Some("t2".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
let t3 = d
.task_spawn(
&WasmTaskSpawnRequest {
scope,
label: Some("t3".to_string()),
cancel_kind: None,
},
None,
)
.unwrap();
assert_eq!(d.handles().live_count(), 5);
d.task_join(
&t1,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(1),
},
None,
)
.unwrap();
d.task_cancel(
&WasmTaskCancelRequest {
task: t2,
kind: "race_lost".to_string(),
message: None,
},
None,
)
.unwrap();
d.task_join(
&t2,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
},
None,
)
.unwrap();
d.task_join(
&t3,
WasmAbiOutcomeEnvelope::Err {
failure: WasmAbiFailure {
code: WasmAbiErrorCode::InternalFailure,
recoverability: WasmAbiRecoverability::Transient,
message: "transient failure".to_string(),
},
},
None,
)
.unwrap();
assert_eq!(d.handles().live_count(), 2);
d.scope_close(&scope, None).unwrap();
d.runtime_close(&rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
assert!(d.handles().detect_leaks().is_empty());
}
#[test]
fn dispatcher_high_frequency_invocation_stress() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
for i in 0u64..100 {
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: Some(format!("task-{i}")),
cancel_kind: None,
},
None,
)
.unwrap();
d.task_join(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::U64(i),
},
None,
)
.unwrap();
}
assert_eq!(d.dispatch_count(), 201); assert_eq!(d.handles().live_count(), 1); assert!(d.handles().detect_leaks().is_empty());
let report = d.handles().memory_report();
assert!(report.capacity <= 100);
d.runtime_close(&rt, None).unwrap();
}
#[test]
fn dispatcher_error_to_outcome_envelope_conversion() {
let errors = [
WasmDispatchError::Incompatible {
decision: WasmAbiCompatibilityDecision::MajorMismatch {
producer_major: 1,
consumer_major: 2,
},
},
WasmDispatchError::Handle(WasmHandleError::SlotOutOfRange {
slot: 5,
table_size: 3,
}),
WasmDispatchError::InvalidState {
state: WasmBoundaryState::Closed,
symbol: WasmAbiSymbol::TaskSpawn,
},
WasmDispatchError::InvalidRequest {
reason: "bad payload".to_string(),
},
];
let expected_codes = [
WasmAbiErrorCode::CompatibilityRejected,
WasmAbiErrorCode::InvalidHandle,
WasmAbiErrorCode::InvalidHandle,
WasmAbiErrorCode::DecodeFailure,
];
for (err, expected_code) in errors.iter().zip(expected_codes.iter()) {
let outcome = err.to_outcome();
match outcome {
WasmAbiOutcomeEnvelope::Err { failure } => {
assert_eq!(failure.code, *expected_code);
assert_eq!(failure.recoverability, WasmAbiRecoverability::Permanent);
assert!(!failure.message.is_empty());
}
_ => panic!("expected Err outcome"),
}
}
}
#[test]
fn dispatcher_backward_compatible_version_accepted() {
let mut d = WasmExportDispatcher::new();
let compat_version = WasmAbiVersion {
major: WASM_ABI_MAJOR_VERSION,
minor: WASM_ABI_MINOR_VERSION + 5,
};
let rt = d.runtime_create(Some(compat_version)).unwrap();
assert_eq!(rt.kind, WasmHandleKind::Runtime);
let event = &d.event_log().events()[0];
assert!(matches!(
event.compatibility,
WasmAbiCompatibilityDecision::BackwardCompatible { .. }
));
}
#[test]
fn dispatcher_request_payload_serialization_round_trip() {
let scope_req = WasmScopeEnterRequest {
parent: WasmHandleRef {
kind: WasmHandleKind::Runtime,
slot: 0,
generation: 0,
},
label: Some("test".to_string()),
};
let json = serde_json::to_string(&scope_req).unwrap();
let back: WasmScopeEnterRequest = serde_json::from_str(&json).unwrap();
assert_eq!(scope_req, back);
let spawn_req = WasmTaskSpawnRequest {
scope: WasmHandleRef {
kind: WasmHandleKind::Region,
slot: 1,
generation: 0,
},
label: Some("worker".to_string()),
cancel_kind: Some("timeout".to_string()),
};
let json = serde_json::to_string(&spawn_req).unwrap();
let back: WasmTaskSpawnRequest = serde_json::from_str(&json).unwrap();
assert_eq!(spawn_req, back);
let cancel_req = WasmTaskCancelRequest {
task: WasmHandleRef {
kind: WasmHandleKind::Task,
slot: 2,
generation: 0,
},
kind: "user".to_string(),
message: Some("cancelled by operator".to_string()),
};
let json = serde_json::to_string(&cancel_req).unwrap();
let back: WasmTaskCancelRequest = serde_json::from_str(&json).unwrap();
assert_eq!(cancel_req, back);
let fetch_req = WasmFetchRequest {
scope: WasmHandleRef {
kind: WasmHandleKind::Region,
slot: 1,
generation: 0,
},
url: "https://example.com".to_string(),
method: "POST".to_string(),
body: Some(vec![1, 2, 3]),
};
let json = serde_json::to_string(&fetch_req).unwrap();
let back: WasmFetchRequest = serde_json::from_str(&json).unwrap();
assert_eq!(fetch_req, back);
}
#[test]
fn dispatcher_nested_scopes_lifecycle() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let s1 = d
.scope_enter(
&WasmScopeEnterRequest {
parent: rt,
label: Some("outer".to_string()),
},
None,
)
.unwrap();
let s2 = d
.scope_enter(
&WasmScopeEnterRequest {
parent: s1,
label: Some("inner".to_string()),
},
None,
)
.unwrap();
assert_eq!(d.handles().live_count(), 3);
d.scope_close(&s2, None).unwrap();
assert_eq!(d.handles().live_count(), 2);
d.scope_close(&s1, None).unwrap();
assert_eq!(d.handles().live_count(), 1);
d.runtime_close(&rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn dispatcher_panicked_outcome_passes_through() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(
&WasmTaskSpawnRequest {
scope: rt,
label: None,
cancel_kind: None,
},
None,
)
.unwrap();
let result = d
.task_join(
&task,
WasmAbiOutcomeEnvelope::Panicked {
message: "boundary panic in task".to_string(),
},
None,
)
.unwrap();
assert_eq!(
result,
WasmAbiOutcomeEnvelope::Panicked {
message: "boundary panic in task".to_string(),
}
);
}
#[test]
fn scope_enter_builder_produces_correct_request() {
let parent = WasmHandleRef {
kind: WasmHandleKind::Runtime,
slot: 0,
generation: 0,
};
let req = WasmScopeEnterBuilder::new(parent).label("test").build();
assert_eq!(req.parent, parent);
assert_eq!(req.label, Some("test".to_string()));
let req = WasmScopeEnterBuilder::new(parent).build();
assert_eq!(req.label, None);
}
#[test]
fn task_spawn_builder_produces_correct_request() {
let scope = WasmHandleRef {
kind: WasmHandleKind::Region,
slot: 1,
generation: 0,
};
let req = WasmTaskSpawnBuilder::new(scope)
.label("worker")
.cancel_kind("timeout")
.build();
assert_eq!(req.scope, scope);
assert_eq!(req.label, Some("worker".to_string()));
assert_eq!(req.cancel_kind, Some("timeout".to_string()));
}
#[test]
fn fetch_builder_defaults_to_get() {
let scope = WasmHandleRef {
kind: WasmHandleKind::Region,
slot: 1,
generation: 0,
};
let req = WasmFetchBuilder::new(scope, "https://example.com").build();
assert_eq!(req.method, "GET");
assert_eq!(req.url, "https://example.com");
assert!(req.body.is_none());
let req = WasmFetchBuilder::new(scope, "https://example.com")
.method("POST")
.body(vec![1, 2, 3])
.build();
assert_eq!(req.method, "POST");
assert_eq!(req.body, Some(vec![1, 2, 3]));
}
#[test]
fn outcome_ext_trait_inspectors() {
let ok = WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42),
};
assert!(ok.is_ok());
assert!(!ok.is_err());
assert!(!ok.is_cancelled());
assert!(!ok.is_panicked());
assert_eq!(ok.ok_value(), Some(&WasmAbiValue::I64(42)));
assert!(ok.err_failure().is_none());
assert_eq!(ok.outcome_kind(), "ok");
let err = WasmAbiOutcomeEnvelope::Err {
failure: WasmAbiFailure {
code: WasmAbiErrorCode::InternalFailure,
recoverability: WasmAbiRecoverability::Transient,
message: "boom".to_string(),
},
};
assert!(err.is_err());
assert!(!err.is_ok());
assert_eq!(
err.err_failure().unwrap().code,
WasmAbiErrorCode::InternalFailure
);
assert_eq!(err.outcome_kind(), "err");
let cancelled = WasmAbiOutcomeEnvelope::Cancelled {
cancellation: WasmAbiCancellation {
kind: "user".to_string(),
phase: "completed".to_string(),
origin_region: "R0".to_string(),
origin_task: None,
timestamp_nanos: 0,
message: None,
truncated: false,
},
};
assert!(cancelled.is_cancelled());
assert!(cancelled.cancellation().is_some());
assert_eq!(cancelled.outcome_kind(), "cancelled");
let panicked = WasmAbiOutcomeEnvelope::Panicked {
message: "oom".to_string(),
};
assert!(panicked.is_panicked());
assert_eq!(panicked.outcome_kind(), "panicked");
}
#[test]
fn create_scoped_runtime_convenience() {
let mut d = WasmExportDispatcher::new();
let (rt, scope) = d.create_scoped_runtime(Some("test"), None).unwrap();
assert_eq!(rt.kind, WasmHandleKind::Runtime);
assert_eq!(scope.kind, WasmHandleKind::Region);
assert_eq!(d.handles().live_count(), 2);
d.close_scoped_runtime(&scope, &rt, None).unwrap();
assert_eq!(d.handles().live_count(), 0);
}
#[test]
fn spawn_with_builder_convenience() {
let mut d = WasmExportDispatcher::new();
let (rt, scope) = d.create_scoped_runtime(None, None).unwrap();
let task = d
.spawn(WasmTaskSpawnBuilder::new(scope).label("worker"), None)
.unwrap();
assert_eq!(task.kind, WasmHandleKind::Task);
d.task_join(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
},
None,
)
.unwrap();
d.close_scoped_runtime(&scope, &rt, None).unwrap();
}
#[test]
fn spawn_and_join_convenience() {
let mut d = WasmExportDispatcher::new();
let (rt, scope) = d.create_scoped_runtime(None, None).unwrap();
let result = d
.spawn_and_join(
scope,
Some("inline-task"),
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::String("done".to_string()),
},
None,
)
.unwrap();
assert!(result.is_ok());
assert_eq!(
result.ok_value(),
Some(&WasmAbiValue::String("done".to_string()))
);
d.close_scoped_runtime(&scope, &rt, None).unwrap();
}
#[test]
fn diagnostic_snapshot_clean_after_full_lifecycle() {
let mut d = WasmExportDispatcher::new();
let (rt, scope) = d.create_scoped_runtime(Some("diag-test"), None).unwrap();
let task = d
.spawn(WasmTaskSpawnBuilder::new(scope).label("t1"), None)
.unwrap();
d.task_join(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::Unit,
},
None,
)
.unwrap();
d.close_scoped_runtime(&scope, &rt, None).unwrap();
let diag = d.diagnostic_snapshot();
assert!(diag.is_clean());
assert!(diag.leaks.is_empty());
assert_eq!(diag.memory_report.live_handles, 0);
assert!(diag.dispatch_count > 0);
}
#[test]
fn diagnostic_snapshot_detects_leaks() {
let mut d = WasmExportDispatcher::new();
let rt = d.runtime_create(None).unwrap();
let task = d
.task_spawn(&WasmTaskSpawnBuilder::new(rt).label("leaked").build(), None)
.unwrap();
d.task_cancel(
&WasmTaskCancelRequest {
task,
kind: "user".to_string(),
message: None,
},
None,
)
.unwrap();
let diag = d.diagnostic_snapshot();
assert!(!diag.is_clean());
assert_eq!(diag.memory_report.live_handles, 2); }
#[test]
fn diagnostic_log_fields_include_expected_keys() {
let mut d = WasmExportDispatcher::new();
d.runtime_create(None).unwrap();
let diag = d.diagnostic_snapshot();
let fields = diag.as_log_fields();
assert!(fields.contains_key("dispatch_count"));
assert!(fields.contains_key("live_handles"));
assert!(fields.contains_key("pinned_count"));
assert!(fields.contains_key("event_count"));
assert!(fields.contains_key("leak_count"));
assert!(fields.contains_key("abi_version"));
assert!(fields.contains_key("clean"));
assert_eq!(fields.get("abi_version"), Some(&"1.0".to_string()));
}
#[test]
fn ergonomic_api_full_lifecycle_with_mixed_outcomes() {
let mut d = WasmExportDispatcher::new();
let (rt, scope) = d.create_scoped_runtime(Some("mixed"), None).unwrap();
let r1 = d
.spawn_and_join(
scope,
Some("ok-task"),
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(1),
},
None,
)
.unwrap();
assert!(r1.is_ok());
let r2 = d
.spawn_and_join(
scope,
Some("err-task"),
WasmAbiOutcomeEnvelope::Err {
failure: WasmAbiFailure {
code: WasmAbiErrorCode::InternalFailure,
recoverability: WasmAbiRecoverability::Transient,
message: "retriable".to_string(),
},
},
None,
)
.unwrap();
assert!(r2.is_err());
assert_eq!(
r2.err_failure().unwrap().recoverability,
WasmAbiRecoverability::Transient
);
let r3 = d
.spawn_and_join(
scope,
Some("cancelled-task"),
WasmAbiOutcomeEnvelope::Cancelled {
cancellation: WasmAbiCancellation {
kind: "timeout".to_string(),
phase: "completed".to_string(),
origin_region: "R0".to_string(),
origin_task: None,
timestamp_nanos: 100,
message: Some("deadline".to_string()),
truncated: false,
},
},
None,
)
.unwrap();
assert!(r3.is_cancelled());
assert_eq!(r3.cancellation().unwrap().kind, "timeout");
d.close_scoped_runtime(&scope, &rt, None).unwrap();
let diag = d.diagnostic_snapshot();
assert!(diag.is_clean());
}
#[test]
fn provider_phase_transitions_valid() {
use ReactProviderPhase::*;
assert!(is_valid_provider_transition(Pending, Initializing));
assert!(is_valid_provider_transition(Initializing, Ready));
assert!(is_valid_provider_transition(Initializing, Failed));
assert!(is_valid_provider_transition(Ready, Disposing));
assert!(is_valid_provider_transition(Disposing, Disposed));
assert!(is_valid_provider_transition(Disposing, Failed));
assert!(is_valid_provider_transition(Disposed, Initializing));
assert!(is_valid_provider_transition(Ready, Ready));
}
#[test]
fn provider_phase_transitions_invalid() {
use ReactProviderPhase::*;
assert!(!is_valid_provider_transition(Pending, Ready));
assert!(!is_valid_provider_transition(Pending, Disposing));
assert!(!is_valid_provider_transition(Ready, Initializing));
assert!(!is_valid_provider_transition(Disposed, Ready));
assert!(!is_valid_provider_transition(Failed, Ready));
assert!(validate_provider_transition(Pending, Ready).is_err());
}
#[test]
fn provider_mount_unmount_lifecycle() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
assert_eq!(provider.phase(), ReactProviderPhase::Pending);
provider.mount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Ready);
assert!(provider.runtime_handle().is_some());
assert!(provider.root_scope_handle().is_some());
provider.unmount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Disposed);
let snap = provider.snapshot();
assert_eq!(snap.child_scope_count, 0);
assert_eq!(snap.active_task_count, 0);
assert_eq!(
snap.transition_history,
vec![
ReactProviderPhase::Pending,
ReactProviderPhase::Initializing,
ReactProviderPhase::Ready,
ReactProviderPhase::Disposing,
ReactProviderPhase::Disposed,
]
);
}
#[test]
fn provider_strict_mode_remount() {
let mut provider = ReactProviderState::new(ReactProviderConfig {
strict_mode_resilient: true,
..Default::default()
});
provider.mount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Ready);
let first_rt = provider.runtime_handle().unwrap();
provider.unmount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Disposed);
provider.mount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Ready);
let second_rt = provider.runtime_handle().unwrap();
assert_ne!(first_rt, second_rt);
provider.unmount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Disposed);
}
#[test]
fn provider_clean_remount_remains_valid_without_strict_mode_resilience() {
let mut provider = ReactProviderState::new(ReactProviderConfig {
strict_mode_resilient: false,
..Default::default()
});
provider.mount().unwrap();
let first_rt = provider.runtime_handle().unwrap();
provider.unmount().unwrap();
provider.mount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Ready);
assert_ne!(provider.runtime_handle().unwrap(), first_rt);
}
#[test]
fn provider_child_scopes_tracked() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
provider.mount().unwrap();
let s1 = provider.create_child_scope(Some("panel-a")).unwrap();
let s2 = provider.create_child_scope(Some("panel-b")).unwrap();
assert_ne!(s1, s2);
let snap = provider.snapshot();
assert_eq!(snap.child_scope_count, 2);
provider.unmount().unwrap();
let snap = provider.snapshot();
assert_eq!(snap.child_scope_count, 0);
}
#[test]
fn provider_task_spawn_and_complete() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
provider.mount().unwrap();
let root_scope = provider.root_scope_handle().unwrap();
let task = provider.spawn_task(root_scope, Some("fetch-user")).unwrap();
assert_eq!(provider.snapshot().active_task_count, 1);
let outcome = provider
.complete_task(
&task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42),
},
)
.unwrap();
assert!(outcome.is_ok());
assert_eq!(provider.snapshot().active_task_count, 0);
provider.unmount().unwrap();
}
#[test]
fn provider_complete_task_error_keeps_task_tracked_for_cleanup() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
provider.mount().unwrap();
let root_scope = provider.root_scope_handle().unwrap();
let task = provider.spawn_task(root_scope, Some("fetch-user")).unwrap();
assert_eq!(provider.snapshot().active_task_count, 1);
let bogus = WasmHandleRef {
generation: task.generation.wrapping_add(1),
..task
};
let err = provider
.complete_task(
&bogus,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42),
},
)
.unwrap_err();
assert!(matches!(err, WasmDispatchError::Handle(_)));
assert_eq!(provider.snapshot().active_task_count, 1);
provider.unmount().unwrap();
}
#[test]
fn provider_rejects_spawning_into_foreign_scope() {
let mut owner = ReactProviderState::new(ReactProviderConfig::default());
let mut intruder = ReactProviderState::new(ReactProviderConfig {
label: "intruder".to_string(),
..Default::default()
});
owner.mount().unwrap();
intruder.mount().unwrap();
let foreign_scope = owner.root_scope_handle().unwrap();
let err = intruder
.spawn_task(foreign_scope, Some("cross-provider-task"))
.unwrap_err();
assert!(matches!(
err,
WasmDispatchError::InvalidRequest { ref reason }
if reason == "scope not owned by provider"
));
assert_eq!(intruder.snapshot().active_task_count, 0);
assert_eq!(owner.snapshot().active_task_count, 0);
intruder.unmount().unwrap();
owner.unmount().unwrap();
}
#[test]
fn provider_rejects_completing_foreign_task() {
let mut owner = ReactProviderState::new(ReactProviderConfig::default());
let mut intruder = ReactProviderState::new(ReactProviderConfig {
label: "intruder".to_string(),
..Default::default()
});
owner.mount().unwrap();
intruder.mount().unwrap();
let owner_root = owner.root_scope_handle().unwrap();
let foreign_task = owner.spawn_task(owner_root, Some("owner-task")).unwrap();
let err = intruder
.complete_task(
&foreign_task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(42),
},
)
.unwrap_err();
assert!(matches!(
err,
WasmDispatchError::InvalidRequest { ref reason }
if reason == "task not tracked by provider"
));
assert_eq!(owner.snapshot().active_task_count, 1);
assert_eq!(intruder.snapshot().active_task_count, 0);
let joined = owner
.complete_task(
&foreign_task,
WasmAbiOutcomeEnvelope::Ok {
value: WasmAbiValue::I64(7),
},
)
.unwrap();
assert_eq!(joined.ok_value(), Some(&WasmAbiValue::I64(7)));
intruder.unmount().unwrap();
owner.unmount().unwrap();
}
#[test]
fn provider_unmount_cancels_active_tasks() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
provider.mount().unwrap();
let root_scope = provider.root_scope_handle().unwrap();
let _t1 = provider.spawn_task(root_scope, Some("task-a")).unwrap();
let _t2 = provider.spawn_task(root_scope, Some("task-b")).unwrap();
assert_eq!(provider.snapshot().active_task_count, 2);
provider.unmount().unwrap();
assert_eq!(provider.phase(), ReactProviderPhase::Disposed);
}
#[test]
fn provider_unmount_failure_preserves_live_handles_for_retry() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
provider.mount().unwrap();
let runtime = provider.runtime_handle().unwrap();
let root_scope = provider.root_scope_handle().unwrap();
let child_scope = provider.create_child_scope(Some("child")).unwrap();
let task = provider.spawn_task(root_scope, Some("fetch-user")).unwrap();
provider.config.consumer_version = Some(WasmAbiVersion { major: 2, minor: 0 });
let err = provider.unmount().unwrap_err();
assert!(matches!(err, WasmDispatchError::Incompatible { .. }));
assert_eq!(provider.phase(), ReactProviderPhase::Failed);
let snapshot = provider.snapshot();
assert_eq!(snapshot.active_task_count, 1);
assert_eq!(snapshot.child_scope_count, 1);
assert_eq!(snapshot.runtime_handle, Some(runtime));
assert_eq!(snapshot.root_scope_handle, Some(root_scope));
assert!(provider.dispatcher.handles().get(&task).is_ok());
assert!(provider.dispatcher.handles().get(&child_scope).is_ok());
assert!(provider.dispatcher.handles().get(&root_scope).is_ok());
assert!(provider.dispatcher.handles().get(&runtime).is_ok());
provider.config.consumer_version = None;
provider.do_unmount().unwrap();
let cleaned = provider.snapshot();
assert_eq!(cleaned.active_task_count, 0);
assert_eq!(cleaned.child_scope_count, 0);
assert!(cleaned.runtime_handle.is_none());
assert!(cleaned.root_scope_handle.is_none());
assert!(
provider.dispatcher.diagnostic_snapshot().is_clean(),
"retry cleanup should release all retained handles"
);
}
#[test]
fn provider_operations_rejected_when_not_ready() {
let mut provider = ReactProviderState::new(ReactProviderConfig::default());
assert!(provider.create_child_scope(Some("x")).is_err());
assert!(
provider
.spawn_task(
WasmHandleRef {
kind: WasmHandleKind::Runtime,
slot: 0,
generation: 0,
},
Some("y"),
)
.is_err()
);
}
#[test]
fn provider_snapshot_diagnostics() {
let mut provider = ReactProviderState::new(ReactProviderConfig {
label: "test-provider".to_string(),
devtools_diagnostics: true,
..Default::default()
});
provider.mount().unwrap();
let snap = provider.snapshot();
assert_eq!(snap.phase, ReactProviderPhase::Ready);
assert_eq!(snap.config.label, "test-provider");
assert!(snap.config.devtools_diagnostics);
assert!(snap.dispatcher_diagnostics.is_some());
assert!(snap.runtime_handle.is_some());
assert!(snap.root_scope_handle.is_some());
provider.unmount().unwrap();
}
#[test]
fn provider_config_default_values() {
let cfg = ReactProviderConfig::default();
assert_eq!(cfg.label, "asupersync");
assert_eq!(cfg.abort_mode, WasmAbortPropagationMode::Bidirectional);
assert!(cfg.strict_mode_resilient);
assert!(!cfg.devtools_diagnostics);
assert!(cfg.consumer_version.is_none());
}
#[test]
fn hook_phase_transitions_valid() {
use ReactHookPhase::*;
assert!(is_valid_hook_transition(Idle, Active));
assert!(is_valid_hook_transition(Idle, Error));
assert!(is_valid_hook_transition(Active, Cleanup));
assert!(is_valid_hook_transition(Cleanup, Unmounted));
assert!(is_valid_hook_transition(Cleanup, Active)); assert!(is_valid_hook_transition(Cleanup, Error));
assert!(is_valid_hook_transition(Unmounted, Active)); assert!(is_valid_hook_transition(Active, Active));
}
#[test]
fn hook_phase_transitions_invalid() {
use ReactHookPhase::*;
assert!(!is_valid_hook_transition(Idle, Cleanup));
assert!(!is_valid_hook_transition(Idle, Unmounted));
assert!(!is_valid_hook_transition(Active, Idle));
assert!(!is_valid_hook_transition(Active, Unmounted));
assert!(!is_valid_hook_transition(Error, Active));
assert!(validate_hook_transition(Idle, Cleanup).is_err());
}
#[test]
fn use_scope_config_defaults() {
let cfg = UseScopeConfig::default();
assert_eq!(cfg.label, "scope");
assert!(cfg.propagate_cancel);
}
#[test]
fn use_scope_snapshot_round_trip() {
let snap = UseScopeSnapshot {
phase: ReactHookPhase::Active,
config: UseScopeConfig::default(),
scope_handle: None,
task_count: 3,
child_scope_count: 1,
};
let json = serde_json::to_string(&snap).unwrap();
let decoded: UseScopeSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn use_task_config_defaults() {
let cfg = UseTaskConfig::default();
assert_eq!(cfg.label, "task");
assert_eq!(cfg.dep_change_policy, TaskDepChangePolicy::CancelAndRestart);
assert!(cfg.memoize_result);
}
#[test]
fn use_task_snapshot_round_trip() {
let snap = UseTaskSnapshot {
phase: ReactHookPhase::Active,
status: UseTaskStatus::Running,
config: UseTaskConfig::default(),
task_handle: None,
scope_handle: None,
spawn_count: 2,
dep_cancel_count: 1,
};
let json = serde_json::to_string(&snap).unwrap();
let decoded: UseTaskSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn use_race_config_defaults() {
let cfg = UseRaceConfig::default();
assert_eq!(cfg.label, "race");
assert_eq!(cfg.max_racers, 8);
assert!(cfg.drain_losers_before_resolve);
}
#[test]
fn racer_snapshot_round_trip() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Task);
let racer = RacerSnapshot {
index: 0,
state: RacerState::Won,
task_handle: h,
label: Some("fast-path".to_string()),
};
let json = serde_json::to_string(&racer).unwrap();
let decoded: RacerSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(racer, decoded);
}
#[test]
fn use_race_snapshot_round_trip() {
let snap = UseRaceSnapshot {
phase: ReactHookPhase::Active,
config: UseRaceConfig::default(),
scope_handle: None,
racers: vec![],
race_count: 1,
has_winner: true,
losers_drained: true,
};
let json = serde_json::to_string(&snap).unwrap();
let decoded: UseRaceSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn use_cancellation_config_defaults() {
let cfg = UseCancellationConfig::default();
assert_eq!(cfg.label, "cancellation");
assert!(!cfg.can_trigger);
}
#[test]
fn use_cancellation_snapshot_round_trip() {
let snap = UseCancellationSnapshot {
phase: ReactHookPhase::Active,
config: UseCancellationConfig {
label: "cancel-observer".to_string(),
can_trigger: true,
},
scope_handle: None,
is_cancelled: true,
cancellation: Some(WasmAbiCancellation {
kind: "timeout".to_string(),
phase: "completed".to_string(),
origin_region: "R0".to_string(),
origin_task: None,
timestamp_nanos: 0,
message: None,
truncated: false,
}),
};
let json = serde_json::to_string(&snap).unwrap();
let decoded: UseCancellationSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn hook_diagnostic_event_round_trip() {
let mut table = WasmHandleTable::new();
let h = table.allocate(WasmHandleKind::Region);
let evt = ReactHookDiagnosticEvent {
hook_kind: ReactHookKind::Scope,
label: "panel".to_string(),
from_phase: ReactHookPhase::Idle,
to_phase: ReactHookPhase::Active,
handles: vec![h],
detail: Some("mounted".to_string()),
};
let json = serde_json::to_string(&evt).unwrap();
let decoded: ReactHookDiagnosticEvent = serde_json::from_str(&json).unwrap();
assert_eq!(evt, decoded);
}
#[test]
fn task_dep_change_policy_serde() {
for policy in [
TaskDepChangePolicy::CancelAndRestart,
TaskDepChangePolicy::DiscardAndRestart,
TaskDepChangePolicy::KeepRunning,
] {
let json = serde_json::to_string(&policy).unwrap();
let decoded: TaskDepChangePolicy = serde_json::from_str(&json).unwrap();
assert_eq!(policy, decoded);
}
}
#[test]
fn hook_kind_serde() {
for kind in [
ReactHookKind::Scope,
ReactHookKind::Task,
ReactHookKind::Race,
ReactHookKind::Cancellation,
] {
let json = serde_json::to_string(&kind).unwrap();
let decoded: ReactHookKind = serde_json::from_str(&json).unwrap();
assert_eq!(kind, decoded);
}
}
#[test]
fn render_environment_supports_wasm() {
assert!(NextjsRenderEnvironment::ClientHydrated.supports_wasm_runtime());
assert!(!NextjsRenderEnvironment::ClientSsr.supports_wasm_runtime());
assert!(!NextjsRenderEnvironment::ServerComponent.supports_wasm_runtime());
assert!(!NextjsRenderEnvironment::EdgeRuntime.supports_wasm_runtime());
assert!(!NextjsRenderEnvironment::NodeServer.supports_wasm_runtime());
}
#[test]
fn render_environment_has_browser_apis() {
assert!(NextjsRenderEnvironment::ClientHydrated.has_browser_apis());
assert!(NextjsRenderEnvironment::ClientSsr.has_browser_apis());
assert!(!NextjsRenderEnvironment::ServerComponent.has_browser_apis());
assert!(!NextjsRenderEnvironment::EdgeRuntime.has_browser_apis());
assert!(!NextjsRenderEnvironment::NodeServer.has_browser_apis());
}
#[test]
fn render_environment_is_server_side() {
assert!(NextjsRenderEnvironment::ServerComponent.is_server_side());
assert!(NextjsRenderEnvironment::EdgeRuntime.is_server_side());
assert!(NextjsRenderEnvironment::NodeServer.is_server_side());
assert!(!NextjsRenderEnvironment::ClientSsr.is_server_side());
assert!(!NextjsRenderEnvironment::ClientHydrated.is_server_side());
}
#[test]
fn render_environment_boundary_mode_mapping() {
assert_eq!(
NextjsRenderEnvironment::ClientSsr.boundary_mode(),
NextjsBoundaryMode::Client
);
assert_eq!(
NextjsRenderEnvironment::ClientHydrated.boundary_mode(),
NextjsBoundaryMode::Client
);
assert_eq!(
NextjsRenderEnvironment::ServerComponent.boundary_mode(),
NextjsBoundaryMode::Server
);
assert_eq!(
NextjsRenderEnvironment::NodeServer.boundary_mode(),
NextjsBoundaryMode::Server
);
assert_eq!(
NextjsRenderEnvironment::EdgeRuntime.boundary_mode(),
NextjsBoundaryMode::Edge
);
}
#[test]
fn render_environment_runtime_fallback_mapping() {
assert_eq!(
NextjsRenderEnvironment::ClientHydrated.runtime_fallback(),
NextjsRuntimeFallback::NoneRequired
);
assert_eq!(
NextjsRenderEnvironment::ClientSsr.runtime_fallback(),
NextjsRuntimeFallback::DeferUntilHydrated
);
assert_eq!(
NextjsRenderEnvironment::ServerComponent.runtime_fallback(),
NextjsRuntimeFallback::UseServerBridge
);
assert_eq!(
NextjsRenderEnvironment::NodeServer.runtime_fallback(),
NextjsRuntimeFallback::UseServerBridge
);
assert_eq!(
NextjsRenderEnvironment::EdgeRuntime.runtime_fallback(),
NextjsRuntimeFallback::UseEdgeBridge
);
for env in [
NextjsRenderEnvironment::ClientSsr,
NextjsRenderEnvironment::ClientHydrated,
NextjsRenderEnvironment::ServerComponent,
NextjsRenderEnvironment::EdgeRuntime,
NextjsRenderEnvironment::NodeServer,
] {
assert!(
!env.runtime_fallback_reason().is_empty(),
"fallback reason should be present for {env:?}"
);
}
}
#[test]
fn capability_matrix_wasm_runtime() {
use NextjsCapability::WasmRuntime;
assert!(is_capability_available(
NextjsRenderEnvironment::ClientHydrated,
WasmRuntime
));
assert!(!is_capability_available(
NextjsRenderEnvironment::ClientSsr,
WasmRuntime
));
assert!(!is_capability_available(
NextjsRenderEnvironment::ServerComponent,
WasmRuntime
));
}
#[test]
fn capability_matrix_server_only() {
use NextjsCapability::{NodeApis, RequestContext, ServerCookies};
for cap in [ServerCookies, RequestContext] {
assert!(is_capability_available(
NextjsRenderEnvironment::ServerComponent,
cap
));
assert!(is_capability_available(
NextjsRenderEnvironment::EdgeRuntime,
cap
));
assert!(is_capability_available(
NextjsRenderEnvironment::NodeServer,
cap
));
assert!(!is_capability_available(
NextjsRenderEnvironment::ClientHydrated,
cap
));
}
assert!(is_capability_available(
NextjsRenderEnvironment::NodeServer,
NodeApis
));
assert!(!is_capability_available(
NextjsRenderEnvironment::EdgeRuntime,
NodeApis
));
}
#[test]
fn capability_matrix_client_only() {
use NextjsCapability::{BrowserStorage, DomAccess, WebWorkers};
for cap in [DomAccess, WebWorkers, BrowserStorage] {
assert!(is_capability_available(
NextjsRenderEnvironment::ClientHydrated,
cap
));
assert!(!is_capability_available(
NextjsRenderEnvironment::ClientSsr,
cap
));
assert!(!is_capability_available(
NextjsRenderEnvironment::ServerComponent,
cap
));
}
}
#[test]
fn anti_pattern_explanations_non_empty() {
let patterns = [
NextjsAntiPattern::WasmImportInServerComponent,
NextjsAntiPattern::RuntimeCallDuringSsr,
NextjsAntiPattern::RuntimeInitInRender,
NextjsAntiPattern::HandlesSharingAcrossRoutes,
NextjsAntiPattern::RuntimeInEdgeMiddleware,
NextjsAntiPattern::BlockingHydration,
NextjsAntiPattern::HandlesInServerActions,
];
for ap in patterns {
assert!(
!ap.explanation().is_empty(),
"anti-pattern {ap:?} has empty explanation"
);
}
}
#[test]
fn navigation_type_runtime_survives() {
assert!(NextjsNavigationType::SoftNavigation.runtime_survives());
assert!(!NextjsNavigationType::HardNavigation.runtime_survives());
assert!(!NextjsNavigationType::PopState.runtime_survives());
}
#[test]
fn bootstrap_transitions_valid() {
use NextjsBootstrapPhase::*;
assert!(is_valid_bootstrap_transition(ServerRendered, Hydrating));
assert!(is_valid_bootstrap_transition(Hydrating, Hydrated));
assert!(is_valid_bootstrap_transition(Hydrating, RuntimeFailed));
assert!(is_valid_bootstrap_transition(Hydrated, RuntimeReady));
assert!(is_valid_bootstrap_transition(Hydrated, RuntimeFailed));
assert!(is_valid_bootstrap_transition(RuntimeReady, Hydrating));
assert!(is_valid_bootstrap_transition(RuntimeReady, ServerRendered));
assert!(is_valid_bootstrap_transition(RuntimeFailed, Hydrating));
assert!(is_valid_bootstrap_transition(RuntimeFailed, ServerRendered));
assert!(is_valid_bootstrap_transition(Hydrated, Hydrated));
}
#[test]
fn bootstrap_transitions_invalid() {
use NextjsBootstrapPhase::*;
assert!(!is_valid_bootstrap_transition(ServerRendered, Hydrated));
assert!(!is_valid_bootstrap_transition(ServerRendered, RuntimeReady));
assert!(!is_valid_bootstrap_transition(Hydrating, RuntimeReady)); assert!(!is_valid_bootstrap_transition(RuntimeFailed, RuntimeReady)); assert!(validate_bootstrap_transition(ServerRendered, RuntimeReady).is_err());
}
#[test]
fn bootstrap_state_idempotent_hydration_reentry() {
let mut state = NextjsBootstrapState::new();
assert_eq!(state.phase(), NextjsBootstrapPhase::ServerRendered);
let changed = state.start_hydration().expect("start hydration");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::Hydrating);
assert_eq!(state.hydration_cycle_count(), 1);
let changed = state.start_hydration().expect("idempotent hydration start");
assert!(!changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::Hydrating);
assert_eq!(state.hydration_cycle_count(), 1);
}
#[test]
fn bootstrap_state_cancelled_then_retry_flow() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state
.mark_runtime_cancelled("navigation interrupted")
.expect("mark cancelled");
assert_eq!(state.phase(), NextjsBootstrapPhase::RuntimeFailed);
assert_eq!(state.last_failure(), Some("navigation interrupted"));
let changed = state.retry_after_failure().expect("retry after failure");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::Hydrating);
assert_eq!(state.hydration_cycle_count(), 2);
assert_eq!(state.last_failure(), None);
}
#[test]
fn bootstrap_retry_requires_runtime_failed_phase() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state.mark_runtime_ready().expect("runtime ready");
let before_log = state.transition_log().to_vec();
let err = state.retry_after_failure().unwrap_err();
assert_eq!(
err,
NextjsBootstrapTransitionError {
from: NextjsBootstrapPhase::RuntimeReady,
to: NextjsBootstrapPhase::Hydrating,
}
);
assert_eq!(state.phase(), NextjsBootstrapPhase::RuntimeReady);
assert_eq!(state.hydration_cycle_count(), 1);
assert_eq!(state.runtime_generation(), 1);
assert_eq!(state.transition_log(), before_log.as_slice());
}
#[test]
fn bootstrap_retry_does_not_relabel_normal_rehydration_paths() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state.mark_runtime_ready().expect("runtime ready");
state.on_hot_reload().expect("hot reload");
let last = state.transition_log().last().expect("transition record");
assert_eq!(last.trigger, NextjsBootstrapTrigger::HotReload);
assert_eq!(last.from, NextjsBootstrapPhase::RuntimeReady);
assert_eq!(last.to, NextjsBootstrapPhase::Hydrating);
}
#[test]
fn bootstrap_state_soft_navigation_is_non_destructive() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state.mark_runtime_ready().expect("runtime ready");
let changed = state
.on_navigation(NextjsNavigationType::SoftNavigation)
.expect("soft navigation");
assert!(!changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::RuntimeReady);
assert_eq!(state.runtime_generation(), 1);
assert_eq!(state.navigation_count(), 1);
}
#[test]
fn bootstrap_state_hard_navigation_resets_phase() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state.mark_runtime_ready().expect("runtime ready");
let changed = state
.on_navigation(NextjsNavigationType::HardNavigation)
.expect("hard navigation");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::ServerRendered);
assert_eq!(state.navigation_count(), 1);
assert_eq!(state.last_failure(), None);
}
#[test]
fn bootstrap_state_hard_navigation_during_hydration_resets_cleanly() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
let changed = state
.on_navigation(NextjsNavigationType::HardNavigation)
.expect("hard navigation during hydration");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::ServerRendered);
assert_eq!(state.navigation_count(), 1);
assert_eq!(state.hydration_cycle_count(), 1);
assert_eq!(state.last_failure(), None);
}
#[test]
fn bootstrap_state_hard_navigation_clears_previous_failure() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state
.mark_runtime_failed("module fetch failed")
.expect("mark failed");
assert_eq!(state.last_failure(), Some("module fetch failed"));
let changed = state
.on_navigation(NextjsNavigationType::HardNavigation)
.expect("hard navigation after failure");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::ServerRendered);
assert_eq!(state.last_failure(), None);
}
#[test]
fn bootstrap_state_hot_reload_forces_rehydration() {
let mut state = NextjsBootstrapState::new();
state.start_hydration().expect("start hydration");
state.complete_hydration().expect("complete hydration");
state.mark_runtime_ready().expect("runtime ready");
let changed = state.on_hot_reload().expect("hot reload");
assert!(changed);
assert_eq!(state.phase(), NextjsBootstrapPhase::Hydrating);
assert_eq!(state.hot_reload_count(), 1);
assert_eq!(state.hydration_cycle_count(), 2);
}
#[test]
fn component_placement_round_trip() {
let placement = NextjsComponentPlacement {
environment: NextjsRenderEnvironment::ClientHydrated,
route_segment: "/dashboard/settings".to_string(),
inside_suspense: true,
inside_error_boundary: false,
layout_depth: 2,
};
let json = serde_json::to_string(&placement).unwrap();
let decoded: NextjsComponentPlacement = serde_json::from_str(&json).unwrap();
assert_eq!(placement, decoded);
}
#[test]
fn integration_snapshot_round_trip() {
let snap = NextjsIntegrationSnapshot {
bootstrap_phase: NextjsBootstrapPhase::RuntimeReady,
environment: NextjsRenderEnvironment::ClientHydrated,
route_segment: "/app".to_string(),
active_provider_count: 1,
wasm_module_loaded: true,
navigation_count: 3,
};
let json = serde_json::to_string(&snap).unwrap();
let decoded: NextjsIntegrationSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(snap, decoded);
}
#[test]
fn anti_pattern_serde() {
for ap in [
NextjsAntiPattern::WasmImportInServerComponent,
NextjsAntiPattern::RuntimeCallDuringSsr,
NextjsAntiPattern::RuntimeInitInRender,
NextjsAntiPattern::HandlesSharingAcrossRoutes,
NextjsAntiPattern::RuntimeInEdgeMiddleware,
NextjsAntiPattern::BlockingHydration,
NextjsAntiPattern::HandlesInServerActions,
] {
let json = serde_json::to_string(&ap).unwrap();
let decoded: NextjsAntiPattern = serde_json::from_str(&json).unwrap();
assert_eq!(ap, decoded);
}
}
}