I’m currently building a Ruby time travel debugger, the idea is that you can trace a part of code or a particular request and you have the whole execution recorded and can examine it forwards and backwards when needed.
The first very naive approach is to use the TracePoint
interface to record every line/call/return
and marshal those items. (Started with as_json
, now using the Marshal
module instead).
This quickly becomes very impractical, with multi-gigabyte traces and 200x overhead. I was currently generating an object marshaling for every variable (instance, local, class, etc) on every line execution.
I’m currently experimenting with some custom tracepoint events on bytecode instructions instead of
on line change. The idea is to emit an event when one of the *set*
instructions are called since
those are the ones that modify something. Then whatever was modified is passed as an event argument
and we just marshal those relating them to their object_id
.
I hate that I have to modify the ruby interpreter for this however, I need to investigate if I can do something similar without having to resort to that.
*set*
instructions
There are several set instructions:
setlocal
, sets a local variable, local variables are referenced by index (VALUE index in the stack) and level (on what nested stack the variable is)setblockparam
, sets a block parameter value, also by indexx and levelsetspecial
, sets a special local variable ($~
,$_
, etc), by keysetinstancevariable
, sets an instance variable, takes anid
(object id?), and the instance variable descriptor (?)setclassvariable
, sets a class variable, takes anid
and class variable descriptor (?)setconstant
setglobal
These above are all the basic opcodes that modify the state, then we have the opt_
opcodes, they
are optimizations for very common combinations of opcodes into one faster opcode.
opt_aset
, for things likerecv[any_obj] = new_val
opt_aset_with
, for things likerecv[str] = new_val
, same as above when the obj is a string
These are all the operations that we care about and that we want to capture.
Once we emit those events we just need to know that object_id A had a modification, and create a trace that looks like this:
{ trace_id: 123, obj_id: 534234, obj: "BAh7BkkiCWhvbGEGOgZFVGkR", lineno: 12, path: "file.rb" }
This states that at line 12 in file.rb, object with id 543234 was modified to this new value. The value is
the Base64 encoded output of Marshal.dump(obj)
.
Marshaling
For now I’m using the Marshal
module, however, I think ideally we would only marshal the top level object, and then store references to nested objects and then store those. That way we have more granular changes.
Since we know exactly what was modified at the object level we need to know everything that references that object.
So, let’s say you have a hash with obj_id 12, that contains another hash of obj_id 14. Something like this:
h = { "a" => { "b" => true }}
This would be stored internally as something like this:
{ "obj_id": 12, "obj": "{ 'a': { ref: 14 }}" }
{ "obj_id": 14, "obj": "{ 'b': true }" }
So when object with obj_id 14 is modified
h['a']['b'] = false
We will receive an event that says that it has been modified and we will know exactly what value the top level parents have.
Ideas
- It would be nice to be able to see the ruby instructions and some raw timing information.
- It would be nice to embed a ruby interpreter and to be able to run ruby expressions at any point with the existing environment
- This is complicated with some objects like
File
, that cannot be marshaled.
- This is complicated with some objects like