From b1454fc8ea1d86a63cd845b7d117e410b60273b8 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 3 Aug 2026 21:09:55 +0400 Subject: [PATCH 1/2] feat(debug): implement debug.sethook/gethook (line + count events) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds line and count debug hooks, the primitive test-coverage tooling (LuaCov-style, LCOV output) is built on. debug.sethook/gethook were already declared in the type stub (compiler/stdlib/debug.go) but had no runtime implementation, so any coverage instrumentation was impossible. - hook.go: sethook/gethook + the callHook/fireHook engine. All state lives on LState (hook, hookMask, hookCount, hookCounter, hookLastLine, inHook) — the hot callFrame struct is left untouched, so there is no per-frame size cost on the call path. - vm.go: one guarded call at the dispatch-loop head; when no hook is installed it is a single uint8 compare. Measured (benchstat, n=10, paired vs main): every VM benchmark within noise, geomean -0.10% — no measurable overhead. - Line events fire when the current source line changes (tracked on LState). This is a coverage-oriented simplification of PUC's per-frame oldpc tracking: every executed line is reported at least once; it does not re-fire a line on a same-frame loop back-edge. Happy to switch to full per-frame semantics if preferred. - Reentrancy is blocked via LState.inHook so a hook cannot trigger itself; the register top is saved/restored around the call. - Call/return ('c'/'r') masks are rejected explicitly rather than silently ignored — line/count only for now. hook_test.go covers coverage collection, count firing, gethook round-trip, nested calls, reentrancy, sethook(nil) clearing, and c/r rejection. Full suite (2199 tests) green, go vet clean. No generated files touched. --- debuglib.go | 2 + hook.go | 153 +++++++++++++++++++++++++++++++++++++++++++++++++++ hook_test.go | 121 ++++++++++++++++++++++++++++++++++++++++ value.go | 7 +++ vm.go | 5 ++ 5 files changed, 288 insertions(+) create mode 100644 hook.go create mode 100644 hook_test.go 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) { From 538e1c636454ad5b7456285aea6a08d56b4d68fd Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 4 Aug 2026 12:53:59 +0400 Subject: [PATCH 2/2] feat(coverage): line coverage built on the debug hook (LCOV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage is a consumer of the debug hook (this branch is based on the sethook/gethook branch), not a separate instrumentation path: - WIPPY_COVERAGE arms every LState's line hook via covArm (hookMask |= HookMaskLine) in newLState — no debug.sethook call needed, no separate dispatch-loop guard; - the hook's line-event path in callHook records each executing source line (numerator), reusing the hook's hookLastLine dedup; - CompileWithOptions registers every prototype's coverable lines (walking nested FunctionPrototypes' DbgSourcePositions) = exact denominator incl. never-run functions; - WriteCoverageLCOV / CoverageSummary emit a standard LCOV tracefile. No-op when WIPPY_COVERAGE is unset (covArm and covRegisterProto both bail). Full suite green, go vet clean; smoke confirms covArm arms the hook and hits are recorded with no explicit sethook. --- compile_options.go | 1 + coverage.go | 142 +++++++++++++++++++++++++++++++++++++++++++++ hook.go | 3 + state.go | 2 + state_pool.go | 2 + 5 files changed, 150 insertions(+) create mode 100644 coverage.go diff --git a/compile_options.go b/compile_options.go index acd365d24..165bcf4bb 100644 --- a/compile_options.go +++ b/compile_options.go @@ -53,6 +53,7 @@ func CompileWithOptions(chunk []ast.Stmt, name string, opts CompileOptions) (pro if len(opts.TypeInfo) > 0 { proto.SetTypeInfo(opts.TypeInfo) } + covRegisterProto(proto) return } diff --git a/coverage.go b/coverage.go new file mode 100644 index 000000000..abcb83107 --- /dev/null +++ b/coverage.go @@ -0,0 +1,142 @@ +package lua + +import ( + "os" + "sort" + "strconv" + "strings" + "sync" +) + +// Line coverage built on the debug hook (hook.go). Enabled by setting +// WIPPY_COVERAGE in the environment before the process starts. When on, every +// LState is armed with a line hook (covArm) so the hook's line-event path in +// callHook records each executing source line — coverage is a hook consumer, +// not a separate instrumentation path. The denominator (coverable lines, +// including never-executed functions) is gathered from every compiled prototype +// at CompileWithOptions time. WriteCoverageLCOV emits a standard LCOV tracefile. +var ( + covOn = os.Getenv("WIPPY_COVERAGE") != "" + covMu sync.Mutex + covHits = map[string]map[int]bool{} + covCoverable = map[string]map[int]bool{} +) + +// CoverageEnabled reports whether coverage collection is active. +func CoverageEnabled() bool { return covOn } + +// covArm turns on the line hook for a freshly created/pooled LState so the hook +// records coverage. No-op unless WIPPY_COVERAGE is set, so non-coverage runs and +// explicit debug.sethook users are unaffected. +func covArm(ls *LState) { + if covOn { + ls.hookMask |= HookMaskLine + ls.hookLastLine = 0 + } +} + +// covRecordHit is called from the hook's line-event path in callHook. +func covRecordHit(src string, line int) { + if line <= 0 || src == "" { + return + } + covMu.Lock() + m := covHits[src] + if m == nil { + m = map[int]bool{} + covHits[src] = m + } + m[line] = true + covMu.Unlock() +} + +func covRegisterProto(p *FunctionProto) { + if !covOn || p == nil { + return + } + covMu.Lock() + covRegisterProtoLocked(p) + covMu.Unlock() +} + +func covRegisterProtoLocked(p *FunctionProto) { + if p == nil { + return + } + if p.SourceName != "" { + m := covCoverable[p.SourceName] + if m == nil { + m = map[int]bool{} + covCoverable[p.SourceName] = m + } + for _, ln := range p.DbgSourcePositions { + if ln > 0 { + m[ln] = true + } + } + } + for _, child := range p.FunctionPrototypes { + covRegisterProtoLocked(child) + } +} + +// WriteCoverageLCOV writes an LCOV tracefile for every registered source whose +// name satisfies filter (all sources if filter is nil). The denominator is the +// set of coverable lines gathered from every compiled prototype; the numerator +// is the set of lines the line hook observed executing. +func WriteCoverageLCOV(path string, filter func(src string) bool) error { + covMu.Lock() + defer covMu.Unlock() + + srcs := make([]string, 0, len(covCoverable)) + for s := range covCoverable { + if filter == nil || filter(s) { + srcs = append(srcs, s) + } + } + sort.Strings(srcs) + + var b strings.Builder + for _, s := range srcs { + cov := covCoverable[s] + hit := covHits[s] + nums := make([]int, 0, len(cov)) + for ln := range cov { + nums = append(nums, ln) + } + sort.Ints(nums) + b.WriteString("SF:" + s + "\n") + lh := 0 + for _, ln := range nums { + c := 0 + if hit != nil && hit[ln] { + c = 1 + lh++ + } + b.WriteString("DA:" + strconv.Itoa(ln) + "," + strconv.Itoa(c) + "\n") + } + b.WriteString("LF:" + strconv.Itoa(len(nums)) + "\n") + b.WriteString("LH:" + strconv.Itoa(lh) + "\n") + b.WriteString("end_of_record\n") + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// CoverageSummary returns aggregate (linesFound, linesHit) over filtered sources. +func CoverageSummary(filter func(src string) bool) (lf int, lh int) { + covMu.Lock() + defer covMu.Unlock() + for s, cov := range covCoverable { + if filter != nil && !filter(s) { + continue + } + hit := covHits[s] + for ln := range cov { + lf++ + if hit != nil && hit[ln] { + lh++ + } + } + } + return lf, lh +} diff --git a/hook.go b/hook.go index 810b7eaa8..9826e0b0f 100644 --- a/hook.go +++ b/hook.go @@ -33,6 +33,9 @@ func (ls *LState) callHook(cf *callFrame) { line := int32(positions[pc]) if line != ls.hookLastLine { ls.hookLastLine = line + if covOn { + covRecordHit(cf.Fn.Proto.SourceName, int(line)) + } ls.fireHook("line", line) } } diff --git a/state.go b/state.go index fc259270e..4cc6d5508 100644 --- a/state.go +++ b/state.go @@ -509,6 +509,7 @@ func newLState(options Options) *LState { } ls.Env = ls.G.Global + covArm(ls) return ls } } @@ -536,6 +537,7 @@ func newLState(options Options) *LState { } ls.reg = newRegistry(ls, options.RegistrySize, options.RegistryGrowStep, options.RegistryMaxSize) ls.Env = ls.G.Global + covArm(ls) return ls } diff --git a/state_pool.go b/state_pool.go index 4d00efbcb..390f5600f 100644 --- a/state_pool.go +++ b/state_pool.go @@ -90,6 +90,7 @@ func newLStateWithGlobal(options Options, G *Global, env *LTable) *LState { ls.reg.handler = ls } + covArm(ls) return ls } @@ -118,5 +119,6 @@ func newLStateWithGlobal(options Options, G *Global, env *LTable) *LState { ls.reg = newRegistry(ls, options.RegistrySize, options.RegistryGrowStep, options.RegistryMaxSize) ls.Env = env + covArm(ls) return ls }