Skip to content

Commit

Permalink
forc test single-step until jump point instead of patching binary (#6731
Browse files Browse the repository at this point in the history
)

## Description

Fixes #6720.

Since configurables started to use encoding v1, it is not possible to
use configurables inside tests, because `forc test` patches the binary
forcing a jump into the test function before configurables are
initialized.

This PR fixes this changing the approach from patching the binary, to
single-stepping the initialization and them manually changing the `PC`
register to the first instruction of the test.

Performance is acceptable, a test with a lot of configurables takes
`572.382µs`.

## Checklist

- [x] I have linked to any relevant issues.
- [x] 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).
- [ ] If my change requires substantial documentation changes, I have
[requested support from the DevRel
team](https://github.com/FuelLabs/devrel-requests/issues/new/choose)
- [x] 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.
- [x] 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).
- [x] I have requested a review from the relevant team or maintainers.

---------

Co-authored-by: Joshua Batty <[email protected]>
Co-authored-by: Kaya Gökalp <[email protected]>
Co-authored-by: IGI-111 <[email protected]>
  • Loading branch information
4 people authored Nov 22, 2024
1 parent 94a0666 commit 278bb8c
Show file tree
Hide file tree
Showing 7 changed files with 721 additions and 53 deletions.
157 changes: 104 additions & 53 deletions forc-test/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ 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::fuel_asm;
use fuel_vm::prelude::Instruction;
use fuel_vm::prelude::RegId;
use fuel_vm::{
self as vm,
checked_transaction::builder::TransactionBuilderExt,
Expand All @@ -27,6 +30,8 @@ pub struct TestExecutor {
pub tx: vm::checked_transaction::Ready<tx::Script>,
pub test_entry: PkgTestEntry,
pub name: String,
pub jump_instruction_index: usize,
pub relative_jump_in_bytes: u32,
}

/// The result of executing a test with breakpoints enabled.
Expand All @@ -41,15 +46,16 @@ pub enum DebugResult {
impl TestExecutor {
pub fn build(
bytecode: &[u8],
test_offset: u32,
test_instruction_index: u32,
test_setup: TestSetup,
test_entry: &PkgTestEntry,
name: String,
) -> anyhow::Result<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();
// Find the instruction which we will jump into the
// specified test
let jump_instruction_index = find_jump_instruction_index(bytecode);

// Create a transaction to execute the test function.
let script_input_data = vec![];
Expand All @@ -68,7 +74,7 @@ impl TestExecutor {
let block_height = (u32::MAX >> 1).into();
let gas_price = 0;

let mut tx_builder = tx::TransactionBuilder::script(bytecode, script_input_data);
let mut tx_builder = tx::TransactionBuilder::script(bytecode.to_vec(), script_input_data);

let params = maxed_consensus_params();

Expand Down Expand Up @@ -126,23 +132,72 @@ impl TestExecutor {
tx,
test_entry: test_entry.clone(),
name,
jump_instruction_index,
relative_jump_in_bytes: (test_instruction_index - jump_instruction_index as u32)
* Instruction::SIZE as u32,
})
}

// single-step until the jump-to-test instruction, then
// jump into the first instruction of the test
fn single_step_until_test(&mut self) -> ProgramState {
let jump_pc = (self.jump_instruction_index * Instruction::SIZE) as u64;

let old_single_stepping = self.interpreter.single_stepping();
self.interpreter.set_single_stepping(true);
let mut state = {
let transition = self.interpreter.transact(self.tx.clone());
Ok(*transition.unwrap().state())
};

loop {
match state {
// if the VM fails, we interpret as a revert
Err(_) => {
break ProgramState::Revert(0);
}
Ok(
state @ ProgramState::Return(_)
| state @ ProgramState::ReturnData(_)
| state @ ProgramState::Revert(_),
) => break state,
Ok(
s @ ProgramState::RunProgram(eval) | s @ ProgramState::VerifyPredicate(eval),
) => {
// time to jump into the specified test
if let Some(b) = eval.breakpoint() {
if b.pc() == jump_pc {
self.interpreter.registers_mut()[RegId::PC] +=
self.relative_jump_in_bytes as u64;
self.interpreter.set_single_stepping(old_single_stepping);
break s;
}
}

state = self.interpreter.resume();
}
}
}
}

/// Execute the test with breakpoints enabled.
pub fn start_debugging(&mut self) -> anyhow::Result<DebugResult> {
let start = std::time::Instant::now();
let transition = self

let _ = self.single_step_until_test();
let state = self
.interpreter
.transact(self.tx.clone())
.map_err(|err: InterpreterError<_>| anyhow::anyhow!(err))?;
let state = *transition.state();
.resume()
.map_err(|err: InterpreterError<_>| {
anyhow::anyhow!("VM failed to resume. {:?}", err)
})?;
if let ProgramState::RunProgram(DebugEval::Breakpoint(breakpoint)) = state {
// A breakpoint was hit, so we tell the client to stop.
return Ok(DebugResult::Breakpoint(breakpoint.pc()));
}

let duration = start.elapsed();
let (gas_used, logs) = Self::get_gas_and_receipts(transition.receipts().to_vec())?;
let (gas_used, logs) = Self::get_gas_and_receipts(self.interpreter.receipts().to_vec())?;
let span = self.test_entry.span.clone();
let file_path = self.test_entry.file_path.clone();
let condition = self.test_entry.pass_condition.clone();
Expand Down Expand Up @@ -192,14 +247,27 @@ impl TestExecutor {

pub fn execute(&mut self) -> anyhow::Result<TestResult> {
let start = std::time::Instant::now();
let transition = self
.interpreter
.transact(self.tx.clone())
.map_err(|err: InterpreterError<_>| anyhow::anyhow!(err))?;
let state = *transition.state();

let mut state = Ok(self.single_step_until_test());

// Run test until its end
loop {
match state {
Err(_) => {
state = Ok(ProgramState::Revert(0));
break;
}
Ok(
ProgramState::Return(_) | ProgramState::ReturnData(_) | ProgramState::Revert(_),
) => break,
Ok(ProgramState::RunProgram(_) | ProgramState::VerifyPredicate(_)) => {
state = self.interpreter.resume();
}
}
}

let duration = start.elapsed();
let (gas_used, logs) = Self::get_gas_and_receipts(transition.receipts().to_vec())?;
let (gas_used, logs) = Self::get_gas_and_receipts(self.interpreter.receipts().to_vec())?;
let span = self.test_entry.span.clone();
let file_path = self.test_entry.file_path.clone();
let condition = self.test_entry.pass_condition.clone();
Expand All @@ -209,7 +277,7 @@ impl TestExecutor {
file_path,
duration,
span,
state,
state: state.unwrap(),
condition,
logs,
gas_used,
Expand Down Expand Up @@ -237,42 +305,25 @@ impl TestExecutor {
}
}

/// 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 i(4 + 2) ; Jumps to the data section setup.
/// [ 1] noop
/// [ 2] DATA_SECTION_OFFSET[0..32]
/// [ 3] DATA_SECTION_OFFSET[32..64]
/// [ 4] CONFIGURABLES_OFFSET[0..32]
/// [ 5] CONFIGURABLES_OFFSET[32..64]
/// [ 6] lw $ds $is 1 ; The data section setup, i.e. where the first ji lands.
/// [ 7] add $$ds $$ds $is
/// [ 8] <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]> {
// Each instruction is 4 bytes,
// so we divide the total byte-size by 4 to get the instruction offset.
const PROGRAM_START_INST_OFFSET: u32 = (sway_core::PRELUDE_SIZE_IN_BYTES / 4) as u32;
const PROGRAM_START_BYTE_OFFSET: usize = sway_core::PRELUDE_SIZE_IN_BYTES;

// 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);
}
fn find_jump_instruction_index(bytecode: &[u8]) -> usize {
// Search first `move $$locbase $sp`
// This will be `__entry` for script/predicate/contract using encoding v1;
// `main` for script/predicate using encoding v0;
// or the first function for libraries
// MOVE R59 $sp ;; [26, 236, 80, 0]
let a = vm::fuel_asm::op::move_(59, fuel_asm::RegId::SP).to_bytes();

// 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)
// for contracts using encoding v0
// search the first `lw $r0 $fp i73`
// which is the start of the fn selector
// LW $writable $fp 0x49 ;; [93, 64, 96, 73]
let b = vm::fuel_asm::op::lw(fuel_asm::RegId::WRITABLE, fuel_asm::RegId::FP, 73).to_bytes();

bytecode
.chunks(Instruction::SIZE)
.position(|instruction| {
let instruction: [u8; 4] = instruction.try_into().unwrap();
instruction == a || instruction == b
})
.unwrap()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[[package]]
name = "configurable_tests"
source = "member"
dependencies = ["std"]

[[package]]
name = "core"
source = "path+from-root-CEAD1EF3DC39BB76"

[[package]]
name = "std"
source = "path+from-root-CEAD1EF3DC39BB76"
dependencies = ["core"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
authors = ["Fuel Labs <[email protected]>"]
entry = "main.sw"
license = "Apache-2.0"
name = "configurable_tests"

[dependencies]
std = { path = "../../../../../../../sway-lib-std" }
Loading

0 comments on commit 278bb8c

Please sign in to comment.