Skip to content

feat(debug): implement debug.sethook/gethook (line + count events) - #34

Open
xepozz wants to merge 1 commit into
wippyai:mainfrom
xepozz:feature/debug-line-count-hook
Open

feat(debug): implement debug.sethook/gethook (line + count events)#34
xepozz wants to merge 1 commit into
wippyai:mainfrom
xepozz:feature/debug-line-count-hook

Conversation

@xepozz

@xepozz xepozz commented Aug 3, 2026

Copy link
Copy Markdown

What

Implements the runtime side of debug.sethook / debug.gethook, supporting line and count events. These functions were already declared in the type stub (compiler/stdlib/debug.go) but had no runtime implementation, so debug.sethook did not exist on an LState at runtime.

Why

Line hooks are the primitive every Lua test-coverage tool is built on (LuaCov and friends register a line hook and record executed lines, then emit an LCOV tracefile). Without sethook there is no way to measure coverage of Lua code running on this VM. This PR adds the minimum needed to enable that. (I have a working LuaCov-style collector + LCOV emitter verified against this branch — happy to share as an example if useful.)

Design

  • hook.go (new) — the whole feature: mask constants, callHook/fireHook, debugSetHook/debugGetHook.
  • All hook state lives on LState (hook, hookMask, hookCount, hookCounter, hookLastLine, inHook). The hot, pooled callFrame struct is left untouched — no per-frame size growth, so the call path pays nothing. No generated files are modified.
  • vm.go — one guarded call at the dispatch-loop head, after the yield-continuation check so it only fires on normal instruction dispatch:
    if L.hookMask != 0 {
        L.callHook(cf)
        cf = L.currentFrame
    }
  • Line semantics — a line event fires when the current source line changes (hookLastLine on LState). This is a deliberate, 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, and same-line tracking is global rather than per-CallInfo. If you'd prefer full per-frame PUC semantics I'm glad to move hookLastLine into a per-frame slot.
  • Reentrancy is blocked via LState.inHook — a hook cannot trigger itself; the register top is saved/restored so the hook is transparent to the interrupted instruction.
  • c/r (call/return) masks are rejected explicitly with an error rather than silently ignored. Line+count is what coverage needs; call/return can be a follow-up.

Performance

The dispatch-loop guard is a single uint8 compare when no hook is installed. Measured with benchstat (n=10, paired against main) across the VM hot-path benchmarks (ForNumeric, ArithIntOnly, LocalAccess, VMFunctionCall, ForPairs, Comparison):

every benchmark: ~ (p > 0.05, within noise)
geomean:         -0.10% vs base

No measurable overhead for runs that do not install a hook.

Semantics

  • debug.sethook(fn, mask [, count])mask may contain l; count > 0 enables the count hook. debug.sethook() / debug.sethook(nil) clears.
  • Hook is called as fn(event, line)event is "line" or "count"; line is the current line for "line", nil otherwise.
  • debug.gethook() returns (fn, maskstring, count).

Tests

hook_test.go (new) covers: coverage collection via a line hook, count-hook firing, gethook round-trip, line events across nested calls, reentrancy safety, sethook(nil) clearing, and c/r rejection.

go test ./   -> 2199 passed
go vet  ./   -> clean

@xepozz
xepozz force-pushed the feature/debug-line-count-hook branch from 9dda1e6 to b9863a6 Compare August 3, 2026 17:20
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.
@wolfy-j

wolfy-j commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

this would not work under v2 semantics... could be the same achieved without lua impact?

@xepozz

xepozz commented Aug 5, 2026

Copy link
Copy Markdown
Author

I'm ok to have it now for v1 and change/remove later. I'd think about capabilities of v2 when v2 will be published.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants