Skip to content

Commit

Permalink
Refactor forc-test to expose VM interpreter (#5409)
Browse files Browse the repository at this point in the history
## Description

Related #2350

This refactor is not intended to change any behavior. It adds
`TestExecutor` which has a handle to `vm::Interpreter`. This will be
used to access the debugger within the VM.

Also moved the "setup" related code into a new file to make the code
more organized.

## Checklist

- [ ] I have linked to any relevant issues.
- [ ] I have commented my code, particularly in hard-to-understand
areas.
- [ ] I have updated the documentation where relevant (API docs, the
reference, and the Sway book).
- [ ] I have added tests that prove my fix is effective or that my
feature works.
- [ ] I have added (or requested a maintainer to add) the necessary
`Breaking*` or `New Feature` labels where relevant.
- [ ] I have done my best to ensure that my PR adheres to [the Fuel Labs
Code Review
Standards](https://github.com/FuelLabs/rfcs/blob/master/text/code-standards/external-contributors.md).
- [ ] I have requested a review from the relevant team or maintainers.

---------

Co-authored-by: Joshua Batty <[email protected]>
  • Loading branch information
sdankel and JoshuaBatty authored Dec 20, 2023
1 parent fe65ca4 commit d3da28d
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 258 deletions.
178 changes: 178 additions & 0 deletions forc-test/src/execute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
use crate::setup::TestSetup;
use crate::TestResult;
use crate::TEST_METADATA_SEED;
use forc_pkg::PkgTestEntry;
use fuel_tx::{self as tx, output::contract::Contract, Chargeable, Finalizable};
use fuel_vm::error::InterpreterError;
use fuel_vm::{
self as vm,
checked_transaction::builder::TransactionBuilderExt,
interpreter::{Interpreter, NotSupportedEcal},
prelude::{Instruction, SecretKey},
storage::MemoryStorage,
};
use rand::{Rng, SeedableRng};

/// An interface for executing a test within a VM [Interpreter] instance.
#[derive(Debug)]
pub struct TestExecutor {
pub interpreter: Interpreter<MemoryStorage, tx::Script, NotSupportedEcal>,
tx_builder: tx::TransactionBuilder<tx::Script>,
test_entry: PkgTestEntry,
name: String,
}

impl TestExecutor {
pub fn new(
bytecode: &[u8],
test_offset: u32,
test_setup: TestSetup,
test_entry: &PkgTestEntry,
name: String,
) -> Self {
let storage = test_setup.storage().clone();

// Patch the bytecode to jump to the relevant test.
let bytecode = patch_test_bytecode(bytecode, test_offset).into_owned();

// Create a transaction to execute the test function.
let script_input_data = vec![];
let rng = &mut rand::rngs::StdRng::seed_from_u64(TEST_METADATA_SEED);

// Prepare the transaction metadata.
let secret_key = SecretKey::random(rng);
let utxo_id = rng.gen();
let amount = 1;
let maturity = 1.into();
let asset_id = rng.gen();
let tx_pointer = rng.gen();

let mut tx_builder = tx::TransactionBuilder::script(bytecode, script_input_data)
.add_unsigned_coin_input(
secret_key,
utxo_id,
amount,
asset_id,
tx_pointer,
0u32.into(),
)
.maturity(maturity)
.clone();

let mut output_index = 1;
// Insert contract ids into tx input
for contract_id in test_setup.contract_ids() {
tx_builder
.add_input(tx::Input::contract(
tx::UtxoId::new(tx::Bytes32::zeroed(), 0),
tx::Bytes32::zeroed(),
tx::Bytes32::zeroed(),
tx::TxPointer::new(0u32.into(), 0),
contract_id,
))
.add_output(tx::Output::Contract(Contract {
input_index: output_index,
balance_root: fuel_tx::Bytes32::zeroed(),
state_root: tx::Bytes32::zeroed(),
}));
output_index += 1;
}
let consensus_params = tx_builder.get_params().clone();

// Temporarily finalize to calculate `script_gas_limit`
let tmp_tx = tx_builder.clone().finalize();
// Get `max_gas` used by everything except the script execution. Add `1` because of rounding.
let max_gas =
tmp_tx.max_gas(consensus_params.gas_costs(), consensus_params.fee_params()) + 1;
// Increase `script_gas_limit` to the maximum allowed value.
tx_builder.script_gas_limit(consensus_params.tx_params().max_gas_per_tx - max_gas);

TestExecutor {
interpreter: Interpreter::with_storage(storage, consensus_params.into()),
tx_builder,
test_entry: test_entry.clone(),
name,
}
}

pub fn execute(&mut self) -> anyhow::Result<TestResult> {
let block_height = (u32::MAX >> 1).into();
let start = std::time::Instant::now();
let transition = self
.interpreter
.transact(self.tx_builder.finalize_checked(block_height))
.map_err(|err: InterpreterError<_>| anyhow::anyhow!(err))?;
let duration = start.elapsed();
let state = *transition.state();
let receipts = transition.receipts().to_vec();

let gas_used = *receipts
.iter()
.find_map(|receipt| match receipt {
tx::Receipt::ScriptResult { gas_used, .. } => Some(gas_used),
_ => None,
})
.ok_or_else(|| anyhow::anyhow!("missing used gas information from test execution"))?;

// Only retain `Log` and `LogData` receipts.
let logs = receipts
.into_iter()
.filter(|receipt| {
matches!(receipt, tx::Receipt::Log { .. })
|| matches!(receipt, tx::Receipt::LogData { .. })
})
.collect();

let span = self.test_entry.span.clone();
let file_path = self.test_entry.file_path.clone();
let condition = self.test_entry.pass_condition.clone();
let name = self.name.clone();
Ok(TestResult {
name,
file_path,
duration,
span,
state,
condition,
logs,
gas_used,
})
}
}

/// Given some bytecode and an instruction offset for some test's desired entry point, patch the
/// bytecode with a `JI` (jump) instruction to jump to the desired test.
///
/// We want to splice in the `JI` only after the initial data section setup is complete, and only
/// if the entry point doesn't begin exactly after the data section setup.
///
/// The following is how the beginning of the bytecode is laid out:
///
/// ```ignore
/// [0] ji i4 ; Jumps to the data section setup.
/// [1] noop
/// [2] DATA_SECTION_OFFSET[0..32]
/// [3] DATA_SECTION_OFFSET[32..64]
/// [4] lw $ds $is 1 ; The data section setup, i.e. where the first ji lands.
/// [5] add $$ds $$ds $is
/// [6] <first-entry-point> ; This is where we want to jump from to our test code!
/// ```
fn patch_test_bytecode(bytecode: &[u8], test_offset: u32) -> std::borrow::Cow<[u8]> {
// TODO: Standardize this or add metadata to bytecode.
const PROGRAM_START_INST_OFFSET: u32 = 6;
const PROGRAM_START_BYTE_OFFSET: usize = PROGRAM_START_INST_OFFSET as usize * Instruction::SIZE;

// If our desired entry point is the program start, no need to jump.
if test_offset == PROGRAM_START_INST_OFFSET {
return std::borrow::Cow::Borrowed(bytecode);
}

// Create the jump instruction and splice it into the bytecode.
let ji = vm::fuel_asm::op::ji(test_offset);
let ji_bytes = ji.to_bytes();
let start = PROGRAM_START_BYTE_OFFSET;
let end = start + ji_bytes.len();
let mut patched = bytecode.to_vec();
patched.splice(start..end, ji_bytes);
std::borrow::Cow::Owned(patched)
}
Loading

0 comments on commit d3da28d

Please sign in to comment.