From a0ae64b4ce3f0e50f76082580254b838f33c182e Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Fri, 1 Mar 2024 18:43:09 +0000 Subject: [PATCH] Debugger (#12) * 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 --- README.md | 9 +- run.go | 29 +++++ runopts/debugger.go | 234 +++++++++++++++++++++++++++++++++++++++ runopts/debugger_test.go | 69 ++++++++++++ runopts/runopts.go | 9 +- 5 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 runopts/debugger.go create mode 100644 runopts/debugger_test.go diff --git a/README.md b/README.md index c3a3573..88355d3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/run.go b/run.go index 9b082f9..6256bc9 100644 --- a/run.go +++ b/run.go @@ -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 { diff --git a/runopts/debugger.go b/runopts/debugger.go new file mode 100644 index 0000000..696500f --- /dev/null +++ b/runopts/debugger.go @@ -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 +} diff --git a/runopts/debugger_test.go b/runopts/debugger_test.go new file mode 100644 index 0000000..b2437a6 --- /dev/null +++ b/runopts/debugger_test.go @@ -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[:]) + } + }) + } +} diff --git a/runopts/runopts.go b/runopts/runopts.go index 936682a..a909afc 100644 --- a/runopts/runopts.go +++ b/runopts/runopts.go @@ -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 })