Skip to content

feat(coverage): Go-side line coverage built on the debug hook (LCOV) - #1

Closed
xepozz wants to merge 1 commit into
feature/debug-line-count-hookfrom
feature/coverage-collector
Closed

feat(coverage): Go-side line coverage built on the debug hook (LCOV)#1
xepozz wants to merge 1 commit into
feature/debug-line-count-hookfrom
feature/coverage-collector

Conversation

@xepozz

@xepozz xepozz commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Depends on wippyai#34 (debug.sethook/gethook). This PR is built on top of that
branch and consumes the hook — please review/merge wippyai#34 first. Once wippyai#34 lands
I'll rebase this onto main (the diff will then be coverage-only).

What

Adds an opt-in line-coverage collector that emits a standard LCOV tracefile.
It is a consumer of the debug hook from wippyai#34, not a separate instrumentation path.

Enable by setting WIPPY_COVERAGE in the environment before the process starts.
The host then calls WriteCoverageLCOV(path, filter) to write the report.

Why

Line hooks are the primitive every Lua coverage tool is built on. wippyai#34 adds the
hook; this PR turns it into a working coverage collector that spans a whole
embedding — every LState, including coroutine/child threads — which is awkward
to achieve with pure-Lua debug.sethook wiring in a host that spawns many states.

Design

  • coverage.go (new) — the collector: covArm, covRecordHit,
    covRegisterProto, WriteCoverageLCOV, CoverageSummary, CoverageEnabled.
  • Numerator (executed lines) rides the hook. When WIPPY_COVERAGE is set,
    covArm arms every new LState with a line hook (hookMask |= HookMaskLine) —
    no debug.sethook call needed. The hook's existing line-event path in
    callHook records the executing source line, reusing its hookLastLine dedup.
    covArm is wired into both newLState and newLStateWithGlobal (the
    coroutine/thread constructor), so child states are covered too.
  • Denominator (coverable lines) is exact. CompileWithOptions registers every
    prototype's DbgSourcePositions, walking nested FunctionPrototypes — so the
    denominator includes never-executed functions, not just lines that ran.
  • WriteCoverageLCOV(path, filter) emits SF/DA/LF/LH/end_of_record;
    CoverageSummary(filter) returns aggregate (found, hit). Collection is
    process-global (merged across all LStates), so a host that runs many states or
    coroutines gets a single report — dump it once at shutdown.

Usage

// Enable before any LState is created (WIPPY_COVERAGE is read at package init):
//   WIPPY_COVERAGE=1 go run .

L := lua.NewState()
defer L.Close()

// ... run your Lua workloads across any number of states / coroutines ...

if lua.CoverageEnabled() {
    // filter by source name; pass nil to include every source.
    filter := func(src string) bool { return strings.HasPrefix(src, "@") }
    if err := lua.WriteCoverageLCOV("coverage.info", filter); err != nil {
        log.Printf("coverage: %v", err)
    }
    found, hit := lua.CoverageSummary(filter)
    log.Printf("coverage: %d/%d lines", hit, found)
}

Source names in SF: are the chunk names you pass to Load/LoadString
(e.g. @path/to/file.lua), so the filter selects which chunks land in the report.
Example output:

SF:@main.lua
DA:1,1
DA:2,0
DA:4,3
LF:3
LH:2
end_of_record

coverage.info is consumed as-is by genhtml, Codecov/Coveralls, and editor
coverage plugins.

Performance

Zero hot-path cost when disabled: covArm and covRegisterProto bail immediately
unless WIPPY_COVERAGE is set, and the dispatch loop uses the hook's existing
if L.hookMask != 0 guard (from wippyai#34) — no additional per-instruction check. With
no hook installed and WIPPY_COVERAGE unset, none of this runs.

Scope of changes

coverage.go (new) + one line in callHook (hook.go), the covArm calls in
state.go / state_pool.go, and one covRegisterProto call in
compile_options.go. No generated files touched.

Verification

  • Full test suite green, go vet clean.
  • Validated end-to-end by embedding the library in a host that spawns many states
    and coroutines, running a workload under WIPPY_COVERAGE, and confirming the
    emitted LCOV parses cleanly in genhtml and reports plausible per-file line
    coverage. A regression where coroutine/child-thread states were left unarmed
    (recording only part of the executed lines) is fixed by arming
    newLStateWithGlobal.

Note for maintainers

This is opinionated tooling rather than a Lua-language feature. If you'd prefer to
keep coverage out of the core, I'm happy to gate it behind a build tag
(//go:build coverage) or maintain it as a fork — just let me know.

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.
@xepozz

xepozz commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Moving to upstream wippyai/go-lua.

@xepozz xepozz closed this Aug 4, 2026
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.

1 participant