Skip to main content

submilli_engine/runtime/
effects.rs

1//! Record native outcomes before exposing them to guest code.
2//!
3//! Argument conversion (including guest callbacks) runs before `execute`; result
4//! conversion runs afterwards. The history port owns ordering and persistence.
5//! An absent history keeps ordinary execution direct. No Wasm handles are encoded.
6use std::io::Write;
7
8use futures::future::BoxFuture;
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10use sha2::{Digest, Sha256};
11use wasmtime::Caller;
12
13use super::StoreData;
14use super::durable::EffectId;
15use super::fuel;
16use super::host::{self, fatal_host_error};
17
18/// Independent of the filesystem journal's much smaller metadata limit.
19pub const MAX_OUTCOME_BYTES: usize = 64 * 1024 * 1024;
20pub const CODEC_VERSION: u32 = 4;
21
22#[derive(Debug, thiserror::Error)]
23#[error("durable execution: {0}")]
24pub struct HistoryError(pub String);
25
26/// A call's identity is allocated by the history implementation, not the interpreter.
27pub struct EffectCall {
28    pub id: EffectId,
29    pub sequence: u64,
30    pub recorded: Option<RecordedOutcome>,
31}
32
33pub type RecordedOutcome = Result<OutcomeBytes, NativeError>;
34
35/// Encoded bytes use MessagePack's binary representation, never an integer array.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(transparent)]
38pub struct OutcomeBytes(#[serde(with = "binary")] pub Vec<u8>);
39
40/// Implementations must serialize calls and commit before `complete` returns.
41/// A missing outcome is an instruction to execute, regardless of idempotency.
42#[async_trait::async_trait]
43pub trait EffectHistory: Send + Sync {
44    async fn begin(
45        &self,
46        operation: &str,
47        fingerprint: [u8; 32],
48    ) -> Result<EffectCall, HistoryError>;
49    async fn complete(
50        &self,
51        call: &EffectCall,
52        outcome: RecordedOutcome,
53    ) -> Result<(), HistoryError>;
54    /// Verify that no effect is pending and all recorded calls have been consumed.
55    async fn ensure_history_consumed(&self) -> Result<(), HistoryError>;
56}
57
58/// Execute one native action, or return its committed native outcome.
59///
60/// `arguments` are hashed in memory only. The action owns them, avoiding copies
61/// of large requests. It must not call guest code or manufacture guest exceptions:
62/// return native values/errors and convert them after this boundary instead.
63pub async fn execute<'store, A, T, F>(
64    caller: &mut Caller<'store, StoreData>,
65    operation: &'static str,
66    arguments: A,
67    action: F,
68) -> wasmtime::Result<T>
69where
70    A: Serialize + Send + 'static,
71    T: Serialize + DeserializeOwned + Send + 'static,
72    F: for<'call> FnOnce(
73            &'call mut Caller<'store, StoreData>,
74            EffectId,
75            A,
76        ) -> BoxFuture<'call, wasmtime::Result<T>>
77        + Send,
78{
79    let Some(history) = caller.data().effects.clone() else {
80        let result = action(caller, EffectId::stub()?, arguments).await;
81        return result.map_err(|error| host::throw_host_error(caller, error));
82    };
83    let fingerprint = fingerprint(caller, &arguments)?;
84    let call = history
85        .begin(operation, fingerprint)
86        .await
87        .map_err(fatal_host_error)?;
88    if let Some(recorded) = &call.recorded {
89        return match recorded {
90            Ok(bytes) => {
91                fuel::charge(&mut *caller, fuel::PARSE, bytes.0.len() as u64)?;
92                decode(&bytes.0)
93            }
94            Err(error) => Err(host::throw_host_error(caller, error.clone().into_error())),
95        };
96    }
97
98    let result = action(caller, call.id.clone(), arguments).await;
99    if let Err(error) = &result
100        && (host::ends_the_run(error) || error.is::<wasmtime::ThrownException>())
101    {
102        return result;
103    }
104    let recorded_result = match &result {
105        Ok(value) => {
106            let encoded = encode(value)?;
107            // Do not commit a representation the replay decoder refuses structurally.
108            validate_outcome(&encoded)?;
109            fuel::settle(&mut *caller, fuel::COPY, encoded.len() as u64)?;
110            Ok(OutcomeBytes(encoded))
111        }
112        Err(error) => Err(NativeError::capture(error)),
113    };
114    history
115        .complete(&call, recorded_result)
116        .await
117        .map_err(fatal_host_error)?;
118    result.map_err(|error| host::throw_host_error(caller, error))
119}
120
121pub(crate) mod binary {
122    use serde::{Deserializer, Serializer};
123    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
124        serializer.serialize_bytes(bytes)
125    }
126    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
127        struct Bytes;
128        impl<'de> serde::de::Visitor<'de> for Bytes {
129            type Value = Vec<u8>;
130            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131                f.write_str("binary outcome")
132            }
133            fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
134                Ok(bytes.to_vec())
135            }
136        }
137        deserializer.deserialize_bytes(Bytes)
138    }
139}
140
141fn fingerprint<A: Serialize>(
142    caller: &mut Caller<'_, StoreData>,
143    arguments: &A,
144) -> wasmtime::Result<[u8; 32]> {
145    let mut writer = FingerprintWriter {
146        caller,
147        hash: Sha256::new(),
148        failure: None,
149    };
150    let result =
151        arguments.serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map());
152    if let Some(error) = writer.failure {
153        return Err(error);
154    }
155    result.map_err(fatal_host_error)?;
156    Ok(writer.hash.finalize().into())
157}
158
159struct FingerprintWriter<'a, 'store> {
160    caller: &'a mut Caller<'store, StoreData>,
161    hash: Sha256,
162    failure: Option<wasmtime::Error>,
163}
164impl Write for FingerprintWriter<'_, '_> {
165    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
166        if let Err(error) = fuel::charge(&mut *self.caller, fuel::HASH, bytes.len() as u64) {
167            self.failure = Some(error);
168            return Err(std::io::Error::other("effect fingerprint exceeded fuel"));
169        }
170        self.hash.update(bytes);
171        Ok(bytes.len())
172    }
173    fn flush(&mut self) -> std::io::Result<()> {
174        Ok(())
175    }
176}
177
178fn encode<T: Serialize>(value: &T) -> wasmtime::Result<Vec<u8>> {
179    let mut writer = OutcomeWriter { bytes: Vec::new() };
180    value
181        .serialize(&mut rmp_serde::Serializer::new(&mut writer).with_struct_map())
182        .map_err(fatal_host_error)?;
183    Ok(writer.bytes)
184}
185struct OutcomeWriter {
186    bytes: Vec<u8>,
187}
188impl Write for OutcomeWriter {
189    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
190        let length = self
191            .bytes
192            .len()
193            .checked_add(bytes.len())
194            .filter(|n| *n <= MAX_OUTCOME_BYTES)
195            .ok_or_else(|| std::io::Error::other("native outcome exceeds history limit"))?;
196        if length > self.bytes.capacity() {
197            let capacity = length
198                .max(self.bytes.capacity().saturating_mul(2))
199                .min(MAX_OUTCOME_BYTES);
200            self.bytes
201                .try_reserve_exact(capacity - self.bytes.len())
202                .map_err(std::io::Error::other)?;
203        }
204        self.bytes.extend_from_slice(bytes);
205        Ok(bytes.len())
206    }
207    fn flush(&mut self) -> std::io::Result<()> {
208        Ok(())
209    }
210}
211fn validate_outcome(bytes: &[u8]) -> wasmtime::Result<()> {
212    if bytes.len() > MAX_OUTCOME_BYTES {
213        return Err(fatal_host_error("native outcome exceeds history limit"));
214    }
215    // Validate lengths, nesting and complete consumption before deserialization.
216    super::effects_memory::decoded_heap_bound(bytes, 1, false)?;
217    Ok(())
218}
219
220fn decode<T: DeserializeOwned>(bytes: &[u8]) -> wasmtime::Result<T> {
221    validate_outcome(bytes)?;
222    let mut decoder = rmp_serde::Deserializer::new(std::io::Cursor::new(bytes));
223    decoder.set_max_depth(128);
224    let value = T::deserialize(&mut decoder).map_err(fatal_host_error)?;
225    if decoder.position() != bytes.len() as u64 {
226        return Err(fatal_host_error("trailing native outcome bytes"));
227    }
228    Ok(value)
229}
230
231/// Guest-visible error data, captured before the host wrapper raises an exception.
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub enum NativeError {
234    Error(String),
235    Range(String),
236    QuotaExceeded(String),
237    Timeout(String),
238    Type(String),
239    Syntax(String),
240    Uri(String),
241    PermissionDenied(host::PermissionDenied),
242}
243impl NativeError {
244    pub(crate) fn capture(error: &wasmtime::Error) -> Self {
245        let message = error.to_string();
246        if let Some(denial) = error.downcast_ref::<host::PermissionDenied>() {
247            return Self::PermissionDenied(denial.clone());
248        }
249        if error.is::<host::RangeError>() {
250            return Self::Range(message);
251        }
252        if error.is::<host::QuotaExceededError>() {
253            return Self::QuotaExceeded(message);
254        }
255        if error.is::<host::TimeoutError>() {
256            return Self::Timeout(message);
257        }
258        if error.is::<host::TypeError>() {
259            return Self::Type(message);
260        }
261        if error.is::<host::SyntaxError>() {
262            return Self::Syntax(message);
263        }
264        if error.is::<host::UriError>() {
265            return Self::Uri(message);
266        }
267        Self::Error(message)
268    }
269    pub(crate) fn into_error(self) -> wasmtime::Error {
270        match self {
271            Self::Error(message) => wasmtime::Error::msg(message),
272            Self::Range(message) => wasmtime::Error::new(host::RangeError(message)),
273            Self::QuotaExceeded(message) => wasmtime::Error::new(host::QuotaExceededError(message)),
274            Self::Timeout(message) => wasmtime::Error::new(host::TimeoutError(message)),
275            Self::Type(message) => wasmtime::Error::new(host::TypeError(message)),
276            Self::Syntax(message) => wasmtime::Error::new(host::SyntaxError(message)),
277            Self::Uri(message) => wasmtime::Error::new(host::UriError(message)),
278            Self::PermissionDenied(denial) => wasmtime::Error::new(denial),
279        }
280    }
281}
282
283/// Validate a stored MessagePack envelope before Serde sees allocation hints.
284/// The caller must bound the blob size before validating the encoded structure.
285/// `element_bytes` is the largest sequence element in the persistence schema.
286pub fn stored_heap_bound(
287    bytes: &[u8],
288    element_bytes: usize,
289    dynamic_maps: bool,
290) -> Result<u64, HistoryError> {
291    super::effects_memory::decoded_heap_bound(bytes, element_bytes, dynamic_maps)
292        .map_err(|error| HistoryError(error.to_string()))
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    #[test]
299    fn native_floats_keep_nan_bits_and_signed_zero() {
300        for bits in [
301            0x8000_0000_0000_0000,
302            0x7ff8_0000_0000_1234,
303            0x7ff0_0000_0000_0000,
304        ] {
305            let encoded = encode(&f64::from_bits(bits)).unwrap();
306            let restored: f64 = rmp_serde::from_slice(&encoded).unwrap();
307            assert_eq!(restored.to_bits(), bits);
308        }
309    }
310    #[test]
311    fn binary_is_not_expanded_into_integer_elements() {
312        let value = OutcomeBytes(vec![255; 4096]);
313        let encoded = encode(&value).unwrap();
314        assert!(encoded.len() < 4100);
315        let restored: OutcomeBytes = rmp_serde::from_slice(&encoded).unwrap();
316        assert_eq!(restored, value);
317        drop(encoded);
318    }
319    #[test]
320    fn missing_and_empty_native_strings_remain_distinct() {
321        for value in [None, Some(String::new()), Some("text".to_owned())] {
322            let encoded = encode(&value).unwrap();
323            let restored: Option<String> = rmp_serde::from_slice(&encoded).unwrap();
324            assert_eq!(restored, value);
325        }
326    }
327}