diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ab68517..5cf3d3f84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,12 @@ jobs: # several minutes to finish on CI runners. run: go test -timeout 25m ./... + - name: Coverage collector regressions + env: + WIPPY_COVERAGE: "1" + GOCACHE: ${{ runner.temp }}/go-build + run: go test -count=1 -run '^TestReviewCoverage' . + race: name: Race Tests (${{ matrix.shard }}) runs-on: ubuntu-latest 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..642997a2b --- /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 enters the shared hook dispatcher, where +// 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 } + +// 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) + } +} + +// coverageSnapshot publishes one coherent view without holding the collector +// lock across caller callbacks or filesystem I/O. +func coverageSnapshot() map[string]map[int]bool { + covMu.Lock() + defer covMu.Unlock() + snapshot := make(map[string]map[int]bool, len(covCoverable)) + for source, coverable := range covCoverable { + lines := make(map[int]bool, len(coverable)) + for line := range coverable { + lines[line] = covHits[source][line] + } + snapshot[source] = lines + } + return snapshot +} + +// 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 shared dispatcher observed executing. +func WriteCoverageLCOV(path string, filter func(src string) bool) error { + snapshot := coverageSnapshot() + srcs := make([]string, 0, len(snapshot)) + for s := range snapshot { + if filter == nil || filter(s) { + srcs = append(srcs, s) + } + } + sort.Strings(srcs) + + var b strings.Builder + for _, s := range srcs { + cov := snapshot[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 cov[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) { + for s, cov := range coverageSnapshot() { + if filter != nil && !filter(s) { + continue + } + for ln := range cov { + lf++ + if cov[ln] { + lh++ + } + } + } + return lf, lh +} 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..dc24256e5 --- /dev/null +++ b/hook.go @@ -0,0 +1,171 @@ +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 when a debug hook or coverage is active. It records +// coverage before dispatching "count" and "line" debug events. User callbacks +// cannot recursively trigger themselves while L.inHook is set. +func (ls *LState) callHook(cf *callFrame) { + // Coverage consumes the same dispatch as debug hooks, including dispatches + // inside a callback. User hook masks and reentrancy must not disable it. + pc := int(cf.Pc - 1) + line := int32(-1) + if (covOn || ls.hookMask&HookMaskLine != 0) && cf.Fn != nil { + positions := cf.Fn.Proto.DbgSourcePositions + if pc >= 0 && pc < len(positions) { + line = int32(positions[pc]) + if covOn { + covRecordHit(cf.Fn.Proto.SourceName, int(line)) + } + } + } + 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 && line >= 0 { + // A source line alone cannot identify a dispatch location: two + // chunks can use the same line number, and loops revisit a line. + fire := cf.Fn.Proto != ls.hookLastProto || pc <= ls.hookLastPC || line != ls.hookLastLine + ls.hookLastProto = cf.Fn.Proto + ls.hookLastPC = pc + ls.hookLastLine = line + if fire { + 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 +} + +// clearHook releases callbacks and dispatch history before disabling or pooling a state. +func (ls *LState) clearHook() { + ls.hook = nil + ls.hookMask = 0 + ls.hookCount = 0 + ls.hookCounter = 0 + ls.hookLastLine = 0 + ls.hookLastProto = nil + ls.hookLastPC = 0 +} + +// 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.clearHook() + return 0 + } + + hook := L.Get(1) + if hook == LNil { + L.clearHook() + 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/precompile.go b/precompile.go index 52601cfec..3ca2ce451 100644 --- a/precompile.go +++ b/precompile.go @@ -25,6 +25,7 @@ func CompileReader(reader io.Reader, name string) (*FunctionProto, error) { // LoadProto creates an LFunction from a precompiled FunctionProto. // This is much faster than Load() since it skips parsing and compilation. func (ls *LState) LoadProto(proto *FunctionProto) *LFunction { + covRegisterProto(proto) ls.injectProtoTypes(proto) return newLFunctionL(proto, ls.currentEnv(), 0) } diff --git a/review_coverage_filter_test.go b/review_coverage_filter_test.go new file mode 100644 index 000000000..21b6dd835 --- /dev/null +++ b/review_coverage_filter_test.go @@ -0,0 +1,98 @@ +package lua_test + +import ( + lua "github.com/wippyai/go-lua" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestReviewCoverageFiltersCanReadCoverage(t *testing.T) { + if !lua.CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), "filter.lua")) + L := lua.NewState() + defer L.Close() + proto, err := lua.CompileString("return 42", source) + if err != nil { + t.Fatal(err) + } + L.Push(L.LoadProto(proto)) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + for _, operation := range []string{"summary", "LCOV"} { + t.Run(operation, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "coverage.info") + done := make(chan error, 1) + go func() { + nested := false + filter := func(s string) bool { + if !nested { + nested = true + lua.CoverageSummary(nil) + } + return s == source + } + if operation == "summary" { + lua.CoverageSummary(filter) + done <- nil + } else { + done <- lua.WriteCoverageLCOV(path, filter) + } + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("coverage callback deadlocked while reading the collector") + } + }) + } +} + +func TestReviewCoverageConcurrentExecutionAndReports(t *testing.T) { + if !lua.CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), "concurrent.lua")) + proto, err := lua.CompileString("return 42", source) + if err != nil { + t.Fatal(err) + } + var workers sync.WaitGroup + for i := 0; i < 4; i++ { + workers.Add(1) + go func() { + defer workers.Done() + L := lua.NewState() + defer L.Close() + for j := 0; j < 30; j++ { + L.Push(L.LoadProto(proto)) + if err := L.PCall(0, 0, nil); err != nil { + t.Error(err) + return + } + } + }() + } + filter := func(s string) bool { return s == source } + path := filepath.Join(t.TempDir(), "concurrent.info") + for i := 0; i < 5; i++ { + if err := lua.WriteCoverageLCOV(path, filter); err != nil { + t.Error(err) + } + found, hit := lua.CoverageSummary(filter) + if found < 1 || hit > found { + t.Errorf("inconsistent snapshot %d/%d", hit, found) + } + } + workers.Wait() + if _, hit := lua.CoverageSummary(filter); hit == 0 { + t.Fatal("concurrent execution supplied no line hits") + } +} diff --git a/review_coverage_load_test.go b/review_coverage_load_test.go new file mode 100644 index 000000000..ef36c0284 --- /dev/null +++ b/review_coverage_load_test.go @@ -0,0 +1,54 @@ +package lua_test + +import ( + lua "github.com/wippyai/go-lua" + "github.com/wippyai/go-lua/compiler/bytecode" + "path/filepath" + "testing" +) + +func TestReviewCoverageRegistersRestoredBytecode(t *testing.T) { + if !lua.CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + for _, loader := range []string{"LoadProto", "NewFunctionFromProto"} { + t.Run(loader, func(t *testing.T) { + original, err := lua.CompileString("return 42", "@review-compiler-identity.lua") + if err != nil { + t.Fatal(err) + } + // Use an identity absent from this process's collector to model + // bytecode compiled and cached by an earlier process. + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), loader+".lua")) + original.SourceName = source + packed, err := bytecode.Dump(original) + if err != nil { + t.Fatal(err) + } + restored, err := bytecode.Undump(packed) + if err != nil { + t.Fatal(err) + } + filter := func(s string) bool { return s == source } + if found, _ := lua.CoverageSummary(filter); found != 0 { + t.Fatal("fixture source was already registered") + } + L := lua.NewState() + defer L.Close() + var fn *lua.LFunction + if loader == "LoadProto" { + fn = L.LoadProto(restored) + } else { + fn = L.NewFunctionFromProto(restored) + } + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + found, hit := lua.CoverageSummary(filter) + if found == 0 || hit == 0 { + t.Fatalf("restored executable source is absent from coverage: %d/%d", hit, found) + } + }) + } +} diff --git a/review_coverage_test.go b/review_coverage_test.go new file mode 100644 index 000000000..603db73ee --- /dev/null +++ b/review_coverage_test.go @@ -0,0 +1,134 @@ +package lua + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReviewCoverageRecordsSeparateChunksWithEqualLineNumbers(t *testing.T) { + if !CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + L := NewState() + defer L.Close() + for _, name := range []string{"first.lua", "second.lua"} { + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), name)) + fn, err := L.Load(strings.NewReader("return 42"), source) + if err != nil { + t.Fatal(err) + } + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + found, hit := CoverageSummary(func(s string) bool { return s == source }) + if found < 1 || hit < 1 { + t.Fatalf("executed source %s has coverage %d/%d", source, hit, found) + } + } +} + +func TestReviewCoverageSurvivesClearingUserDebugHooks(t *testing.T) { + if !CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + L := NewState() + defer L.Close() + if err := L.DoString(`debug.sethook(nil)`); err != nil { + t.Fatal(err) + } + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), "after-clear.lua")) + fn, err := L.Load(strings.NewReader("return 42"), source) + if err != nil { + t.Fatal(err) + } + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + found, hit := CoverageSummary(func(s string) bool { return s == source }) + if found < 1 || hit < 1 { + t.Fatalf("clearing debug hook disabled coverage: %d/%d", hit, found) + } +} + +func TestReviewCoverageIncludesDebugHookCallbackCode(t *testing.T) { + if !CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + L := NewState() + defer L.Close() + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), "callback.lua")) + fn, err := L.Load(strings.NewReader("return function()\n local n = 42\n return n\nend"), source) + if err != nil { + t.Fatal(err) + } + L.Push(fn) + if err := L.PCall(0, 1, nil); err != nil { + t.Fatal(err) + } + L.SetGlobal("reviewCoverageCallback", L.Get(-1)) + L.Pop(1) + if err := L.DoString(`debug.sethook(reviewCoverageCallback, "l"); local n=42; debug.sethook()`); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "callback.info") + if err := WriteCoverageLCOV(path, func(s string) bool { return s == source }); err != nil { + t.Fatal(err) + } + trace, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, bodyLine := range []string{"DA:2,1\n", "DA:3,1\n"} { + if !strings.Contains(string(trace), bodyLine) { + t.Fatalf("executed callback body line missing: %s", trace) + } + } +} + +func TestReviewCoverageRetainsUnexecutedFunctionLines(t *testing.T) { + if !CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + L := NewState() + defer L.Close() + source := "@" + filepath.ToSlash(filepath.Join(t.TempDir(), "unexecuted.lua")) + fn, err := L.Load(strings.NewReader("local function unused()\n return 99\nend\nreturn 42"), source) + if err != nil { + t.Fatal(err) + } + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "unexecuted.info") + if err := WriteCoverageLCOV(path, func(s string) bool { return s == source }); err != nil { + t.Fatal(err) + } + trace, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(trace), "DA:2,0\n") { + t.Fatalf("unexecuted function body needs zero credit: %s", trace) + } +} + +func TestReviewCoveragePreservesUserHookMask(t *testing.T) { + if !CoverageEnabled() { + t.Skip("requires WIPPY_COVERAGE=1") + } + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local callback = function() end + debug.sethook(callback, "", 7) + local hook, mask, count = debug.gethook() + assert(hook == callback and mask == "" and count == 7) + debug.sethook() + assert(debug.gethook() == nil) + `) +} diff --git a/review_hook_isolation_test.go b/review_hook_isolation_test.go new file mode 100644 index 000000000..c4d512f9f --- /dev/null +++ b/review_hook_isolation_test.go @@ -0,0 +1,91 @@ +package lua + +import ( + "strings" + "testing" +) + +func TestReviewDebugHookObservesDistinctChunksWithTheSameLineNumber(t *testing.T) { + L := NewState() + defer L.Close() + hits := 0 + L.SetGlobal("reviewRecordLine", L.NewFunction(func(L *LState) int { + if L.CheckString(1) == "line" { + hits++ + } + return 0 + })) + if err := L.DoString(`debug.sethook(reviewRecordLine, "l")`); err != nil { + t.Fatal(err) + } + hits = 0 + for _, source := range []string{"@review-first.lua", "@review-second.lua"} { + fn, err := L.Load(strings.NewReader("return 42"), source) + if err != nil { + t.Fatal(err) + } + L.Push(fn) + if err := L.PCall(0, 0, nil); err != nil { + t.Fatal(err) + } + } + if hits < 2 { + t.Fatalf("both executed chunks need line events, got %d", hits) + } +} + +func TestReviewPoolResetClearsDebugHookAuthority(t *testing.T) { + L := NewState() + defer L.Close() + if err := L.DoString(`debug.sethook(function() end, "l", 7)`); err != nil { + t.Fatal(err) + } + resetLState(L) + if (L.hook != nil && L.hook != LNil) || L.hookMask != 0 || L.hookCount != 0 || L.hookCounter != 0 || L.hookLastLine != 0 || L.hookLastProto != nil || L.hookLastPC != 0 || L.inHook { + t.Fatal("pooled state retains the prior owner's hook or hook accounting") + } +} + +func TestReviewDebugHookObservesSameLineLoopBackEdges(t *testing.T) { + L := NewState() + defer L.Close() + hits := 0 + L.SetGlobal("reviewRecordLoop", L.NewFunction(func(L *LState) int { + if L.CheckString(1) == "line" { + hits++ + } + return 0 + })) + if err := L.DoString(`debug.sethook(reviewRecordLoop, "l")`); err != nil { + t.Fatal(err) + } + hits = 0 + if err := L.DoString(`local n = 0; for i=1,5 do n=n+i end; assert(n==15)`); err != nil { + t.Fatal(err) + } + if hits < 5 { + t.Fatalf("same-line loop needs repeated line events, got %d", hits) + } +} + +func TestReviewChangingHookDoesNotReenterActiveCallback(t *testing.T) { + L := NewState() + defer L.Close() + errorIfScriptFail(t, L, ` + local inside, armed, reentered = false, false, false + local function second() if inside then reentered = true end end + local function first() + if armed then return end + armed = true + inside = true + debug.sethook(nil) + debug.sethook(second, "l") + local value = 42 + inside = false + end + debug.sethook(first, "l") + local value = 42 + debug.sethook() + assert(not reentered, "changing hooks reentered the active callback") + `) +} diff --git a/state.go b/state.go index 6ed247ccf..e54a9f6f4 100644 --- a/state.go +++ b/state.go @@ -1481,6 +1481,7 @@ func (ls *LState) NewThread() (*LState, context.CancelFunc) { } func (ls *LState) NewFunctionFromProto(proto *FunctionProto) *LFunction { + covRegisterProto(proto) ls.injectProtoTypes(proto) return newLFunctionL(proto, ls.Env, int(proto.NumUpvalues)) } diff --git a/state_pool.go b/state_pool.go index ddcde7c3f..13914ee89 100644 --- a/state_pool.go +++ b/state_pool.go @@ -44,6 +44,10 @@ func resetLState(ls *LState) { ls.wrapped = false ls.yieldState = yieldNone + // Debug callbacks and their dispatch history belong to the prior owner. + ls.clearHook() + ls.inHook = false + // Clear frame extensions to prevent stale continuations from being invoked ls.frameExt = nil } diff --git a/value.go b/value.go index a8c8f6fb4..8f118a5fb 100644 --- a/value.go +++ b/value.go @@ -244,6 +244,15 @@ 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 dispatched source line + hookLastProto *FunctionProto // prototype owning the prior dispatch + hookLastPC int // prior dispatch PC; detects same-line loop back-edges + 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 72c5193c2..17b2043e6 100644 --- a/vm.go +++ b/vm.go @@ -75,6 +75,11 @@ func mainLoopWithContext(L *LState, baseframe *callFrame) { continue } + if L.hookMask != 0 || covOn { + 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) {