Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions debuglib.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
153 changes: 153 additions & 0 deletions hook.go
Original file line number Diff line number Diff line change
@@ -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
}
121 changes: 121 additions & 0 deletions hook_test.go
Original file line number Diff line number Diff line change
@@ -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")
`)
}
7 changes: 7 additions & 0 deletions value.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
5 changes: 5 additions & 0 deletions vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down