diff --git a/debuglib.go b/debuglib.go index eabf0bb28..ddaa33f47 100644 --- a/debuglib.go +++ b/debuglib.go @@ -7,9 +7,11 @@ import ( var debugFuncs = map[string]LGoFunc{ "getinfo": debugGetInfo, + "gethook": debugGetHook, "getlocal": debugGetLocal, "getmetatable": debugGetMetatable, "getupvalue": debugGetUpvalue, + "sethook": debugSetHook, "setlocal": debugSetLocal, "setmetatable": debugSetMetatable, "setupvalue": debugSetUpvalue, diff --git a/hook.go b/hook.go new file mode 100644 index 000000000..810b7eaa8 --- /dev/null +++ b/hook.go @@ -0,0 +1,153 @@ +package lua + +// Debug hook masks, matching the C API constants (lua.h LUA_MASKxxx). +const ( + HookMaskCall uint8 = 1 << iota // "c" + HookMaskReturn // "r" + HookMaskLine // "l" + HookMaskCount // set when a count > 0 is given +) + +// callHook is invoked from the interpreter dispatch loop before each +// instruction executes, but only when L.hookMask != 0. It emits the "count" +// and "line" debug events. It is a no-op while a hook is already running +// (L.inHook) so a hook cannot recursively trigger itself. +func (ls *LState) callHook(cf *callFrame) { + if ls.inHook { + return + } + + if ls.hookMask&HookMaskCount != 0 && ls.hookCount > 0 { + ls.hookCounter++ + if ls.hookCounter >= ls.hookCount { + ls.hookCounter = 0 + ls.fireHook("count", -1) + cf = ls.currentFrame + } + } + + if ls.hookMask&HookMaskLine != 0 && cf.Fn != nil { + positions := cf.Fn.Proto.DbgSourcePositions + pc := int(cf.Pc - 1) + if pc >= 0 && pc < len(positions) { + line := int32(positions[pc]) + if line != ls.hookLastLine { + ls.hookLastLine = line + ls.fireHook("line", line) + } + } + } +} + +// fireHook calls the registered hook function as hook(event, line). For events +// without a line ("count") the second argument is nil. Reentrancy is blocked +// via ls.inHook for the duration of the call. The register top is saved and +// restored so the hook is transparent to the interrupted instruction. +func (ls *LState) fireHook(event string, line int32) { + if ls.hook == nil || ls.hook == LNil { + return + } + + top := ls.reg.Top() + ls.inHook = true + defer func() { + ls.inHook = false + ls.reg.SetTop(top) + }() + + ls.Push(ls.hook) + ls.Push(LString(event)) + if line >= 0 { + ls.Push(LNumber(line)) + } else { + ls.Push(LNil) + } + ls.Call(2, 0) +} + +func parseHookMask(mask string, count int) uint8 { + var m uint8 + for _, c := range mask { + switch c { + case 'c': + m |= HookMaskCall + case 'r': + m |= HookMaskReturn + case 'l': + m |= HookMaskLine + } + } + if count > 0 { + m |= HookMaskCount + } + return m +} + +func hookMaskString(mask uint8) string { + s := "" + if mask&HookMaskCall != 0 { + s += "c" + } + if mask&HookMaskReturn != 0 { + s += "r" + } + if mask&HookMaskLine != 0 { + s += "l" + } + return s +} + +// debugSetHook implements debug.sethook([hook, mask [, count]]). +// Only "line" and "count" events are emitted; requesting "call"/"return" +// hooks is rejected rather than silently ignored. +func debugSetHook(L *LState) int { + if L.GetTop() == 0 { + L.hook = LNil + L.hookMask = 0 + L.hookCount = 0 + L.hookCounter = 0 + return 0 + } + + hook := L.Get(1) + if hook == LNil { + L.hook = LNil + L.hookMask = 0 + L.hookCount = 0 + L.hookCounter = 0 + return 0 + } + if _, ok := hook.(*LFunction); !ok { + L.ArgError(1, "function expected") + } + + mask := L.CheckString(2) + count := L.OptInt(3, 0) + for _, c := range mask { + if c == 'c' || c == 'r' { + L.RaiseError("debug.sethook: call/return hooks are not supported (line/count only)") + } + } + + L.hook = hook + L.hookMask = parseHookMask(mask, count) + L.hookCount = count + L.hookCounter = 0 + L.hookLastLine = 0 + return 0 +} + +// debugGetHook implements debug.gethook(), returning +// (hook function, mask string, count). +func debugGetHook(L *LState) int { + if L.hookMask == 0 || L.hook == nil { + L.Push(LNil) + L.Push(LString("")) + L.Push(LNumber(0)) + return 3 + } + L.Push(L.hook) + L.Push(LString(hookMaskString(L.hookMask))) + L.Push(LNumber(L.hookCount)) + return 3 +} diff --git a/hook_test.go b/hook_test.go new file mode 100644 index 000000000..902b35250 --- /dev/null +++ b/hook_test.go @@ -0,0 +1,121 @@ +package lua + +import "testing" + +func TestDebugLineHookCollectsCoverage(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local seen = {} + local function record(event, line) + assert(event == "line", "unexpected event: "..tostring(event)) + seen[line] = true + end + debug.sethook(record, "l") + local a = 1 + local b = 2 + local c = a + b + debug.sethook() + assert(c == 3) + local n = 0 + for _ in pairs(seen) do n = n + 1 end + assert(n >= 3, "expected >=3 distinct lines, got "..n) + `) +} + +func TestDebugCountHookFires(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local calls = 0 + debug.sethook(function(event) + assert(event == "count", "unexpected event: "..tostring(event)) + calls = calls + 1 + end, "", 1) + local x = 0 + for i = 1, 10 do x = x + i end + debug.sethook() + assert(x == 55) + assert(calls > 0, "count hook never fired") + `) +} + +func TestDebugGetHookRoundtrip(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local f = function() end + debug.sethook(f, "l", 5) + local h, mask, cnt = debug.gethook() + assert(h == f, "hook fn mismatch") + assert(mask == "l", "mask mismatch: "..tostring(mask)) + assert(cnt == 5, "count mismatch: "..tostring(cnt)) + debug.sethook() + local h2, mask2 = debug.gethook() + assert(h2 == nil, "hook not cleared") + assert(mask2 == "", "mask not cleared: "..tostring(mask2)) + `) +} + +func TestDebugLineHookAcrossNestedCalls(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local n = 0 + debug.sethook(function(event, line) n = n + 1 end, "l") + local function inner() + local z = 41 + return z + 1 + end + local r = inner() + local after = r + 0 + debug.sethook() + assert(r == 42) + assert(after == 42) + assert(n >= 4, "expected line events across nested call, got "..n) + `) +} + +func TestDebugHookNoReentry(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local count = 0 + local function noise() return 1 + 1 end + debug.sethook(function(event, line) + count = count + 1 + noise() + end, "l") + local a = 1 + local b = 2 + local c = a + b + debug.sethook() + assert(c == 3) + assert(count > 0, "hook never fired") + assert(count < 1000, "hook reentered itself: "..count) + `) +} + +func TestDebugSethookRejectsCallReturnMask(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local ok1, err1 = pcall(debug.sethook, function() end, "c") + assert(not ok1, "expected error for call hook") + assert(string.find(err1, "not supported"), "unexpected error: "..tostring(err1)) + local ok2 = pcall(debug.sethook, function() end, "r") + assert(not ok2, "expected error for return hook") + assert(debug.gethook() == nil, "rejected sethook must not install a hook") + `) +} + +func TestDebugSethookClearWithNil(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + debug.sethook(function() end, "l") + assert(debug.gethook() ~= nil) + debug.sethook(nil) + assert(debug.gethook() == nil, "sethook(nil) did not clear") + `) +} diff --git a/value.go b/value.go index fdf55f821..e402e5ce0 100644 --- a/value.go +++ b/value.go @@ -239,6 +239,13 @@ type LState struct { yieldContRA int32 // target register for continuation result yieldContRB int32 // call's ReturnBase (where the result lands) yieldContIdx int16 // frame Idx that owns this continuation + + hook LValue // debug hook function, or nil (see hook.go) + hookMask uint8 // active HookMaskXxx bits + hookCount int // count-hook period, 0 = disabled + hookCounter int // instructions since the last count hook fired + hookLastLine int32 // last source line the line-hook fired on + inHook bool // true while the hook function itself is running } func (ls *LState) String() string { return fmt.Sprintf("thread: %p", ls) } diff --git a/vm.go b/vm.go index 1a0ef19f2..764ab7ad4 100644 --- a/vm.go +++ b/vm.go @@ -74,6 +74,11 @@ func mainLoopWithContext(L *LState, baseframe *callFrame) { continue } + if L.hookMask != 0 { + L.callHook(cf) + cf = L.currentFrame + } + // Note: Some opcodes (CALL, TAILCALL, RETURN) may need to `return` from mainLoop // Others just `continue` to next instruction switch int(inst >> 26) {