Skip to content

Commit

Permalink
Debugger (#12)
Browse files Browse the repository at this point in the history
* chore: rename `runopts.FuncOption` to `runopts.Func` to remove stutter

* feat: debugger!!! (UI still to come)

* doc: refactor README features

* feat: `specialops.Code.StartDebugging()` method for easier goroutine management

* feat: `runopts.Debugger.FastForward()` to reach end of execution
  • Loading branch information
aschlosberg authored Mar 1, 2024
1 parent 7cc3b4c commit a0ae64b
Show file tree
Hide file tree
Showing 5 changed files with 344 additions and 6 deletions.
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ bytecode unchanged.
- [ ] Automatic stack permutation
- [ ] Standalone compiler
- [x] In-process EVM execution
- [ ] Interactive debugger
- [x] Debugger
* [x] Stepping
* [ ] Breakpoints
* [x] Programmatic inspection (e.g. native Go tests at opcode resolution)
* [x] Memory
* [x] Stack
* [ ] User interface
- [ ] Fork testing with RPC URL

### Documentation

Expand Down
29 changes: 29 additions & 0 deletions run.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ func (c Code) Run(callData []byte, opts ...runopts.Option) ([]byte, error) {
return runBytecode(compiled, callData, opts...)
}

// StartDebugging appends a runopts.Debugger (`dbg`) to the Options, calls
// c.Run() in a new goroutine, and returns `dbg` along with a function to
// retrieve ther esults of Run(). The function will block until Run() returns,
// i.e. when dbg.Done() returns true. There is no need to call dbg.Wait().
//
// If execution never completes, such that dbg.Done() always returns false, then
// the goroutine will be leaked.
func (c Code) StartDebugging(callData []byte, opts ...runopts.Option) (*runopts.Debugger, func() ([]byte, error)) {
dbg := runopts.NewDebugger()
opts = append(opts, dbg)

var (
result []byte
err error
)
done := make(chan struct{})
go func() {
result, err = c.Run(callData, opts...)
close(done)
}()

dbg.Wait()

return dbg, func() ([]byte, error) {
<-done
return result, err
}
}

func runBytecode(compiled, callData []byte, opts ...runopts.Option) ([]byte, error) {
cfg, err := newRunConfig(opts...)
if err != nil {
Expand Down
234 changes: 234 additions & 0 deletions runopts/debugger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package runopts

import (
"sync"

"github.com/ethereum/go-ethereum/core/vm"
)

// NewDebugger constructs a new Debugger.
//
// Execution SHOULD be advanced until Debugger.Done() returns true otherwise
// resources will be leaked. Best practice is to always call FastForward(),
// usually in a deferred function.
func NewDebugger() *Debugger {
started := make(chan started)
step := make(chan step)
fastForward := make(chan fastForward)
stepped := make(chan stepped)
done := make(chan done)

// The outer and inner values have complementary send-receive abilities,
// hence the duplication. This provides compile-time guarantees of intended
// usage. The sending side is responsible for closing the channel.
return &Debugger{
started: started,
step: step,
fastForward: fastForward,
stepped: stepped,
done: done,
d: &debugger{
started: started,
step: step,
fastForward: fastForward,
stepped: stepped,
done: done,
},
}
}

// For stricter channel types as there are otherwise many with void types that
// can be accidentally switched.
type (
started struct{}
step struct{}
fastForward struct{}
stepped struct{}
done struct{}
)

// A Debugger is an Option that intercepts opcode execution to allow inspection
// of the stack, memory, etc.
type Debugger struct {
d *debugger

// Send external signals
step chan<- step
fastForward chan<- fastForward
// Receive internal state changes
started <-chan started
stepped <-chan stepped
done <-chan done
}

// Apply adds a VMConfig.Tracer to the Configuration, intercepting execution of
// every opcode.
func (d *Debugger) Apply(c *Configuration) error {
c.VMConfig.Tracer = d.d
return nil
}

// Wait blocks until the bytecode is ready for execution, but the first opcode
// is yet to be executed; see Step().
func (d *Debugger) Wait() {
<-d.started
}

// close releases all resources; it MUST NOT be called before `done` is closed.
func (d *Debugger) close(closeFastForward bool) {
close(d.step)
if closeFastForward {
close(d.fastForward)
}
}

// Step advances the execution by one opcode. Step MUST NOT be called
// concurrently with any other Debugger methods. The first opcode is only
// executed upon the first call to Step(), allowing initial state to be
// inspected beforehand.
//
// Step MUST NOT be called after Done() returns true.
func (d *Debugger) Step() {
d.step <- step{}
<-d.stepped

select {
case <-d.done:
d.close(true)
default:
}
}

// FastForward executes all remaining opcodes, effectively the same as calling
// Step() in a loop until Done() returns true.
//
// Unlike Step(), calling FastForward() when Done() returns true is acceptable.
// This allows it to be called in a deferred manner, which is best practice to
// avoid leaking resources.
func (d *Debugger) FastForward() {
select {
case <-d.d.fastForward: // already closed:
return
default:
}

close(d.fastForward)
for {
select {
case <-d.stepped: // gotta catch 'em all
case <-d.done:
d.close(false)
return
}
}
}

// Done returns whether exeuction has ended.
func (d *Debugger) Done() bool {
select {
case <-d.done:
return true
default:
return false
}
}

// State returns the last-captured state, which will be modified upon each call
// to Step(). It is expected that State() only be called once, at any time after
// construction of the Debugger, and its result retained for inspection at each
// Step(). The CapturedState is, however, only valid after the first call to
// Step().
//
// Ownership of pointers is retained by the EVM instance that created
// them; modify with caution!
func (d *Debugger) State() *CapturedState {
return &d.d.last
}

// CapturedState carries all values passed to the debugger.
//
// N.B. See ownership note in Debugger.State() documentation.
type CapturedState struct {
PC, GasLeft, GasCost uint64
Op vm.OpCode
ScopeContext *vm.ScopeContext // contains memory and stack ;)
ReturnData []byte
Err error
}

// debugger implements vm.EVMLogger and is injected by its parent Debugger to
// intercept opcode execution.
type debugger struct {
vm.EVMLogger // no need for most methods so just embed the interface

// Waited upon by CaptureState(), signalling an external call to Step().
step <-chan step
fastForward <-chan fastForward
stepped chan<- stepped
// Closed by Capture{State,Fault}(), externally signalling the start of
// execution.
started chan<- started
startOnce sync.Once
// Closed after execution of one of {STOP,RETURN,REVERT}, or upon a fault,
// externally signalling completion of the execution.
done chan<- done

last CapturedState
}

func (d *debugger) setStarted() {
d.startOnce.Do(func() {
close(d.started)
})
}

// NOTE: when directly calling EVMInterpreter.Run(), only Capture{State,Fault}
// will ever be invoked.

func (d *debugger) CaptureState(pc uint64, op vm.OpCode, gasLeft, gasCost uint64, scope *vm.ScopeContext, retData []byte, depth int, err error) {
d.setStarted()

// TODO: with the <-d.step at the beginning we can inspect initial state,
// but what is actually available and how do we surface it? Perhaps Apply()
// can keep a copy of the *Configuration and access the StateDB.
select {
case <-d.step:
case <-d.fastForward:
}

defer func() {
switch op {
case vm.STOP, vm.RETURN, vm.REVERT:
// Unlike d.started, we don't use a sync.Once for this because
// if it's called twice then we have a bug and want to know
// about it.
close(d.stepped)
close(d.done)
default:
d.stepped <- stepped{}
}
}()

d.last.PC = pc
d.last.Op = op
d.last.GasLeft = gasLeft
d.last.GasCost = gasCost
d.last.ScopeContext = scope
d.last.ReturnData = retData
d.last.Err = err
}

func (d *debugger) CaptureFault(pc uint64, op vm.OpCode, gasLeft, gasCost uint64, scope *vm.ScopeContext, depth int, err error) {
d.setStarted()
defer func() { close(d.done) }()

// TODO: communicate the fault to the user

d.last.PC = pc
d.last.Op = op
d.last.GasLeft = gasLeft
d.last.GasCost = gasCost
d.last.ScopeContext = scope
d.last.ReturnData = nil
d.last.Err = err
}
69 changes: 69 additions & 0 deletions runopts/debugger_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package runopts_test

import (
"bytes"
"fmt"
"testing"

. "github.com/solidifylabs/specialops"
)

func TestDebugger(t *testing.T) {
const retVal = 42
code := Code{
PUSH0, PUSH(1), PUSH(2),
Fn(MSTORE, PUSH(0), PUSH(retVal)),
Fn(RETURN, PUSH0, PUSH(32)),
}

wantPCs := []uint64{0}
pcIncrs := []uint64{
1, // PUSH0
2, // PUSH1
2, // PUSH1
2, // PUSH1
1, // PUSH0
1, // MSTORE
2, // PUSH1
1, // PUSH0
// RETURN
}
for i, incr := range pcIncrs {
wantPCs = append(wantPCs, wantPCs[i]+incr)
}

for ffAt, steps := 0, len(wantPCs); ffAt < steps; ffAt++ { // using range wantPCs, while the same, is misleading
t.Run(fmt.Sprintf("fast-forward after step %d", ffAt), func(t *testing.T) {
dbg, results := code.StartDebugging(nil)
defer dbg.FastForward() // best practice to avoid resource leakage

state := dbg.State() // can be called any time

for step := 0; !dbg.Done(); step++ {
t.Run("step", func(t *testing.T) {
dbg.Step()
if got, want := state.PC, wantPCs[step]; got != want {
t.Errorf("%T.State().PC got %d; want %d", dbg, got, want)
}
if err := state.Err; err != nil {
t.Errorf("%T.State().Err got %v; want nil", dbg, err)
}
})

if step == ffAt {
dbg.FastForward()
if !dbg.Done() {
t.Errorf("%T.Done() after %T.FastForward() got false; want true", dbg, dbg)
}
}
}

got, err := results()
var want [32]byte
want[31] = retVal
if err != nil || !bytes.Equal(got, want[:]) {
t.Errorf("%T.StartDebugging() results function returned %#x, err = %v; want %#x; nil error", code, got, err, want[:])
}
})
}
}
9 changes: 4 additions & 5 deletions runopts/runopts.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,18 @@ type Option interface {
Apply(*Configuration) error
}

// A FuncOption converts any function into an Option by calling itself as
// Apply().
type FuncOption func(*Configuration) error
// A Func converts a function into an Option by calling itself as Apply().
type Func func(*Configuration) error

// Apply returns f(c).
func (f FuncOption) Apply(c *Configuration) error {
func (f Func) Apply(c *Configuration) error {
return f(c)
}

// ReadOnly sets the `readOnly` argument to true when calling
// EVMInterpreter.Run(), equivalent to a static call.
func ReadOnly() Option {
return FuncOption(func(c *Configuration) error {
return Func(func(c *Configuration) error {
c.ReadOnly = true
return nil
})
Expand Down

0 comments on commit a0ae64b

Please sign in to comment.