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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/modules-interfaces/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ pub struct ReportedResult {
/// what a caller in another executor folds.
pub small_hash: bytes::Bytes,

/// `FatalVmError` is legal only while transporting a nested result; a
/// top-level report must publish the same payload as `VmError`
pub kind: ResultCode,
pub data: genlayer_calldata::unparsed::Maybe<genlayer_calldata::Value>,
pub backtrace: Option<Backtrace>,
Expand Down
11 changes: 7 additions & 4 deletions docs/adr/013. pre-validating untrusted decoded inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ hiding a leader whose execution diverged into extra blocks. Now: if the leader
supplied more results than blocks run, the run's result is replaced by
`leader_fault nondet_output extra <h>`, `<h>` = first 6 chars of
`gvm32(sha3_256(b))` where `b` is the **full `[result_code][data]` buffer** the
run would otherwise have returned (`RunOk::as_bytes()`) — fingerprinting the
run would otherwise have returned — fingerprinting the
discarded result rather than losing it. This is **not** a non-deterministic
disagreement (it's a property of the counts, not a re-executed block). No
fix-point escape is needed: a `leader_fault nondet_output` code is never
Expand All @@ -114,13 +114,16 @@ Before the computed result is pushed to the host (and hashed):
- A `VMError`'s fused ` # <detail>` suffix is **stripped** — the nondet channel
is detail-free (diagnostic, no compat promise, and a free-form byte channel
into the hash otherwise). The local `cause` is kept for logs.
- **Nothing else.** The leader must *not* run Rule 1 on its own output:
- A fatal VM error is not encoded into `nondet_results`; it propagates through
the caller and is downgraded to `VMError` only at the topmost publication
boundary
- **Nothing else is rewritten.** The leader must *not* run Rule 1 on its own output:
self-filtering can only rewrite an honest result into a derived-namespace code
that validators replace again — a guaranteed honest-vs-honest hash split.
Rule 1 is for hostile input, Rule 1b for trusted output.

Stripping suffices because the rest is valid by construction: codes come from
the generated constructors and canonical `exit_code <i32>` (a generated test
The remaining encodable results are valid by construction: codes come from the
generated constructors and canonical `exit_code <i32>` (a generated test
asserts every constructor passes the validity check). The one arbitrary-string
site, the `vmError "…"` fee builtin, is unreachable from a nondet child (every
bucket that raises it needs a permission the child lacks); the child also can't
Expand Down
10 changes: 7 additions & 3 deletions docs/adr/014. manager host socket protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,13 @@ flushes its buffered host writers before exiting, which the ACK round-trip
used to force implicitly). The method is deleted from the shared `host-fns`
enum and from both executor lines.

`consume_result` is untouched: it routes to host 1 and is how the manager
obtains a trusted copy of the result without round-tripping it through the
node. Orthogonal to this change.
`consume_result` still routes to host 1 and is how the manager obtains the
result without round-tripping it through the node. For a top-level run, the
manager validates the outer framing and decoded `ReportedResult` before
retaining it. Malformed reports are refused; a fatal result triggers a debug
assertion and is logged and downgraded to `VMError` in release builds. Embedded
`nondet_results` remain opaque because their encoding belongs to the executor
line

### HTTP surface

Expand Down
5 changes: 3 additions & 2 deletions docs/website/src/impl-spec/02-vm/03-consensus.rst
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ followed by an encoded payload:
- ``VmError`` — UTF-8 error code from
:doc:`/spec/appendix/constants` ``vm_error``.

The validator reconstructs an ``rt::vm::RunOk`` from these bytes
(``genlayer_sdk.rs:1502``) and the contract observes the same value as the leader did.
The validator reconstructs a catchable contract outcome from these bytes and
the contract observes the same value as the leader did. Any other result code,
including ``FatalVmError``, is treated as malformed leader input

Validator Comparison
--------------------
Expand Down
18 changes: 18 additions & 0 deletions docs/website/src/impl-spec/appendix/manager-socket.rst
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,24 @@ started with one, ``host_genvm_id``. Variants:
"artifact_sizes": { "stdout": u64, "stderr": u64,
"genvm_log": u64 } } }

For a top-level run, ``consumed_result`` is one outer ``ResultCode`` byte
followed by a calldata-encoded ``ReportedResult`` map. Before retaining it, the
manager checks that:

#. The outer byte is a known result code and agrees with the map's ``kind``
#. The map decodes completely
#. ``execution_hash`` and ``small_hash`` are each 32 bytes unless the result is
``InternalError``

An invalid report is refused without an acknowledgement and is not published
as ``consumed_result``. ``FatalVmError`` is also illegal at this boundary: a
debug manager asserts, while a release manager logs the executor violation and
rewrites both result-code locations to ``VmError`` before publication. Clients
therefore never receive a top-level ``FatalVmError``

The manager does not decode entries of the reported ``nondet_results`` vector.
Those bytes remain opaque, executor-line-specific consensus proposals

Lifecycle guarantees:

- Exactly one terminal event (``failed_to_start`` or ``finished``) per run,
Expand Down
14 changes: 13 additions & 1 deletion docs/website/src/spec/03-vm/05-result.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ set. A non-fatal error of a :term:`sub-VM` is returned to its caller as
terminates with the same VM error, and propagation continues until the topmost
VM boundary

Nested transport encodes a fatal VM error with result code ``4``. At the
topmost publication boundary, the executor MUST downgrade it to an ordinary
:ref:`gvm-def-vm-error` with the same payload before producing the reported
result. Result code ``4`` is therefore forbidden in a top-level reported
result. Both its :ref:`gvm-def-execution-hash` and its
:ref:`gvm-def-subvm-hash` use ``VMError`` as the result kind

.. _gvm-def-vm-error-code:

VM Error Code Format
Expand Down Expand Up @@ -105,7 +112,9 @@ Non-Deterministic Block Result Encoding
- :ref:`gvm-def-vm-error`\: utf-8 string

These three are the only codes a leader-proposed non-deterministic block result
may carry; validators treat every other byte as a malformed leader result
may carry; validators treat every other byte as a malformed leader result. A
fatal VM error computed by a leader's non-deterministic child propagates to its
caller and MUST NOT be encoded into ``nondet_results``

Contract Result Encoding
------------------------
Expand Down Expand Up @@ -176,6 +185,9 @@ with the following keys (in this order):
Two runs that agree on the deterministic result produce the same execution hash, so
consensus can compare a single 32-byte value instead of the full result.

A fatal VM error is committed with ``VMError`` as ``kind``. Fatality controls
propagation and is not a distinct consensus-visible outcome

``emissions`` covers the whole content of every emitted message and event, not
just its metered cost: two emissions can carry different calldata or different
event topics for the same fee, so a fee-only commitment would let nodes agree on
Expand Down
99 changes: 83 additions & 16 deletions implementation/src/manager/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1387,29 +1387,84 @@ fn nested_internal_error() -> genvm_modules_interfaces::NestedRunReply {
}
}

fn nested_reply_from_consumed_result(
fn result_code_from_byte(
value: u8,
source: &str,
) -> anyhow::Result<genvm_modules_interfaces::ResultCode> {
match value {
0 => Ok(genvm_modules_interfaces::ResultCode::Return),
1 => Ok(genvm_modules_interfaces::ResultCode::UserError),
2 => Ok(genvm_modules_interfaces::ResultCode::VmError),
3 => Ok(genvm_modules_interfaces::ResultCode::InternalError),
4 => Ok(genvm_modules_interfaces::ResultCode::FatalVmError),
value => anyhow::bail!("{source} returned unknown result code {value}"),
}
}

fn decode_reported_result(
data: &[u8],
) -> anyhow::Result<genvm_modules_interfaces::NestedRunReply> {
source: &str,
) -> anyhow::Result<(
genvm_modules_interfaces::ResultCode,
genvm_modules_interfaces::ReportedResult,
)> {
let (&kind, encoded) = data
.split_first()
.ok_or_else(|| anyhow::anyhow!("nested executor returned an empty result"))?;
let kind = match kind {
0 => genvm_modules_interfaces::ResultCode::Return,
1 => genvm_modules_interfaces::ResultCode::UserError,
2 => genvm_modules_interfaces::ResultCode::VmError,
3 => genvm_modules_interfaces::ResultCode::InternalError,
4 => genvm_modules_interfaces::ResultCode::FatalVmError,
value => anyhow::bail!("nested executor returned unknown result code {value}"),
};
.ok_or_else(|| anyhow::anyhow!("{source} returned an empty result"))?;
let kind = result_code_from_byte(kind, source)?;
let reported: genvm_modules_interfaces::ReportedResult = calldata::decode_obj(encoded)?;
// The code is stated twice: once as the framing byte, once inside the
// reported map that the execution hash commits to. A disagreement means the
// callee is not the implementation we think it is.
// The framing byte and reported map must name the same committed result
anyhow::ensure!(
kind == reported.kind,
"nested executor result code {kind:?} disagrees with the reported {:?}",
"{source} result code {kind:?} disagrees with the reported {:?}",
reported.kind
);

Ok((kind, reported))
}

fn downgrade_fatal_reported_result(
mut reported: genvm_modules_interfaces::ReportedResult,
) -> Vec<u8> {
debug_assert_eq!(
reported.kind,
genvm_modules_interfaces::ResultCode::FatalVmError
);
reported.kind = genvm_modules_interfaces::ResultCode::VmError;
let mut normalized = vec![genvm_modules_interfaces::ResultCode::VmError as u8];
normalized.extend(calldata::encode_obj(&reported));
normalized
}

fn guard_top_level_consumed_result(data: Vec<u8>, genvm_id: GenVMId) -> anyhow::Result<Vec<u8>> {
let (kind, reported) = decode_reported_result(&data, "top-level executor")?;
if kind != genvm_modules_interfaces::ResultCode::InternalError {
anyhow::ensure!(
reported.execution_hash.len() == 32,
"top-level executor returned an invalid execution hash length"
);
anyhow::ensure!(
reported.small_hash.len() == 32,
"top-level executor returned an invalid small hash length"
);
}
if kind != genvm_modules_interfaces::ResultCode::FatalVmError {
return Ok(data);
}

debug_assert_ne!(
kind,
genvm_modules_interfaces::ResultCode::FatalVmError,
"top-level executor returned a fatal VM error after its publication boundary"
);
log_error_into!(&LoggerWithId, genvm_id:id = genvm_id.0; "top-level executor returned a fatal VM error; downgrading it to vm_error");
Ok(downgrade_fatal_reported_result(reported))
}

fn nested_reply_from_consumed_result(
data: &[u8],
) -> anyhow::Result<genvm_modules_interfaces::NestedRunReply> {
let (kind, reported) = decode_reported_result(data, "nested executor")?;
if kind != genvm_modules_interfaces::ResultCode::InternalError {
anyhow::ensure!(
reported.small_hash.len() == 32,
Expand Down Expand Up @@ -1507,6 +1562,7 @@ fn read_manager_host_stream(
parent_req: Arc<Request>,
consumed_result: sync::DArc<tokio::sync::OnceCell<Vec<u8>>>,
genvm_id: GenVMId,
is_top_level: bool,
stream_state: Arc<ManagerHostStreamState>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
Box::pin(async move {
Expand Down Expand Up @@ -1548,6 +1604,17 @@ fn read_manager_host_stream(
return;
}
};
let data = if is_top_level {
match guard_top_level_consumed_result(data, genvm_id) {
Ok(data) => data,
Err(e) => {
log_error_into!(&LoggerWithId, genvm_id:id = genvm_id.0, error:ah = &e; "refusing invalid top-level consume_result");
return;
}
}
} else {
data
};
log_debug_into!(&LoggerWithId, genvm_id:id = genvm_id.0, len = data.len(); "manager received consume_result");
let _ = consumed_result.set(data);

Expand Down Expand Up @@ -2330,7 +2397,6 @@ async fn run_genvm_process(
_ => req.selector.clone(),
};
let version = resolve_selector(&full_ctx.ver_ctx, &selector, req.timestamp, genvm_id).await?;

let ctx = full_ctx.clone().into_gep(|x| &x.run_ctx);

// Capture controls how logs and stdout/stderr are kept: disabled (forwarded
Expand Down Expand Up @@ -2443,6 +2509,7 @@ async fn run_genvm_process(
Arc::new(req.clone()),
consumed_result,
genvm_id,
is_top_level,
manager_stream_state.clone(),
));

Expand Down
68 changes: 68 additions & 0 deletions implementation/src/manager/run_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,74 @@ fn encoded_nested_result(reported: &genvm_modules_interfaces::ReportedResult) ->
encoded
}

#[test]
fn top_level_guard_accepts_a_valid_report() {
let encoded = encoded_nested_result(&clean_reported());

assert_eq!(
guard_top_level_consumed_result(encoded.clone(), GenVMId(1)).unwrap(),
encoded
);
}

#[test]
fn top_level_guard_rejects_disagreeing_result_codes() {
let mut encoded = encoded_nested_result(&clean_reported());
encoded[0] = genvm_modules_interfaces::ResultCode::VmError as u8;

assert!(guard_top_level_consumed_result(encoded, GenVMId(1)).is_err());
}

#[test]
fn top_level_guard_rejects_invalid_framing() {
for encoded in [Vec::new(), vec![5], vec![0]] {
assert!(guard_top_level_consumed_result(encoded, GenVMId(1)).is_err());
}
}

#[test]
fn top_level_guard_rejects_invalid_hash_lengths() {
let mut reported = clean_reported();
reported.execution_hash = bytes::Bytes::new();

assert!(guard_top_level_consumed_result(encoded_nested_result(&reported), GenVMId(1)).is_err());
}

#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "fatal VM error after its publication boundary")]
fn top_level_guard_asserts_on_fatal_in_debug_builds() {
let mut reported = clean_reported();
reported.kind = genvm_modules_interfaces::ResultCode::FatalVmError;

let _ = guard_top_level_consumed_result(encoded_nested_result(&reported), GenVMId(1));
}

#[cfg(not(debug_assertions))]
#[test]
fn top_level_guard_downgrades_fatal_in_release_builds() {
let mut reported = clean_reported();
reported.kind = genvm_modules_interfaces::ResultCode::FatalVmError;

let encoded =
guard_top_level_consumed_result(encoded_nested_result(&reported), GenVMId(1)).unwrap();
let (kind, reported) = decode_reported_result(&encoded, "test").unwrap();

assert_eq!(kind, genvm_modules_interfaces::ResultCode::VmError);
assert_eq!(reported.kind, genvm_modules_interfaces::ResultCode::VmError);
}

#[test]
fn fatal_downgrade_updates_both_result_codes() {
let mut reported = clean_reported();
reported.kind = genvm_modules_interfaces::ResultCode::FatalVmError;
let encoded = downgrade_fatal_reported_result(reported);
let (kind, reported) = decode_reported_result(&encoded, "test").unwrap();

assert_eq!(kind, genvm_modules_interfaces::ResultCode::VmError);
assert_eq!(reported.kind, genvm_modules_interfaces::ResultCode::VmError);
}

fn some_storage_delta() -> genvm_modules_interfaces::StorageDelta {
genvm_modules_interfaces::StorageDelta::new([0; 36], vec![1])
}
Expand Down
6 changes: 2 additions & 4 deletions tests/runner/origin/base_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ def decode(cls, raw: typing.Any) -> 'ConsumedResult':
empty = not as_bytes
if not empty:
result_kind = host_fns.ResultCode(as_bytes[0])
if result_kind == host_fns.ResultCode.FATAL_VM_ERROR:
raise ValueError('fatal_vm_error crossed the top-level result boundary')
decoded = gvm_calldata.decode(as_bytes[1:])
except Exception as exc:
# Unreadable bytes are a protocol violation rather than a result, so
Expand All @@ -540,10 +542,6 @@ def decode(cls, raw: typing.Any) -> 'ConsumedResult':
return cls.internal_error('empty_result')
if not isinstance(decoded, dict):
return cls.internal_error('result is not a mapping')
# The executor reports fatality; degrading it to an ordinary VM error
# is the host's job, so that a caller in another major can still see it
if result_kind == host_fns.ResultCode.FATAL_VM_ERROR:
result_kind = host_fns.ResultCode.VM_ERROR
return cls(
execution_hash=decoded.get('execution_hash', b''),
result_kind=result_kind,
Expand Down
Loading
Loading