Skip to content

Coverage: a tail-called body is still a body that ran - #175

Merged
revarbat merged 1 commit into
mainfrom
fix/tail-call-body-coverage
Sep 12, 2026
Merged

revarbat merged 1 commit into
mainfrom
fix/tail-call-body-coverage

Conversation

@revarbat

Copy link
Copy Markdown
Member

Reported from the GUI overlay as "a ternary whose condition isn't covered, but whose branches are" — which should be impossible.

What was happening

recordTailCallHop never recorded the coverage hit that enterUserCall does. A tail call hops the frame instead of pushing one, so it never reaches enterUserCall — and a hop is the only way into a tail-called body. A function reached only in tail position reported its body as never run, while its own arms reported hits.

There is no span for a ternary's condition — the body span covers it. So an uncovered body paints the declaration line and the condition red while the covered arms stay green, which is exactly what it looked like on screen.

BOSL2's _str_split_recurse is the shape that shows it, because recursion through a ternary arm is a tail call every time:

L239 body    hits=0    function _str_split_recurse(str,sep,i,result) =
L240 branch  hits=1    concat(result,[str])
L241 branch  hits=2    let(...)

Now 3, 1, 2 — the body's count is the sum of its arms, as it must be.

Minimal repro

Same function, same work, only the call shape differs:

function inner(i)          = i == 0 ? "done" : inner(i-1);
function tail_caller(i)    = inner(i);            // inner's body: 0 hits
function nontail_caller(i) = str("", inner(i));   // inner's body: 1 hit

The fix

One coverHit(calleeDecl) in recordTailCallHop, beside the noteActiveDeclExit/noteActiveDeclEnter pair already there for the same reason: a hop swaps which declaration occupies the frame, so every piece of per-declaration bookkeeping has to follow it. Putting it there rather than at the Op::CallFnTail call site keeps it on the one path every hop takes.

Tests

Coverage.VmAndInterpreterAgree would have caught this years ago if anything in it recursed in tail position — nothing did. The new Coverage.TailCalledBodyIsCounted runs under both the VM and the interpreter and asserts the body count equals the sum of the arms. Verified it fails with the fix reverted.

1258 C++ tests and 42 binding tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb

recordTailCallHop never recorded the coverage hit that enterUserCall
does. A tail call HOPS the frame instead of pushing one, so it never
reaches enterUserCall -- and a hop is the only way into a tail-called
body. A function reached only in tail position therefore reported its
body as never run while its own ternary arms reported hits.

On screen that reads as something impossible: the condition is uncovered
but both branches are covered. There is no span for a ternary's
condition -- the body span covers it -- so an uncovered body paints the
declaration and the condition red while the covered arms stay green.

BOSL2's _str_split_recurse is the shape that shows it, and recursion
through a ternary arm is a tail call every time:

    L239 body   hits=0    function _str_split_recurse(...) =
    L240 branch hits=1    concat(result,[str])
    L241 branch hits=2    let(...)

Now 3, 1 and 2 -- the body's count is the sum of its arms, as it must be.

Minimal repro, same function reached two ways:

    function inner(i)          = i == 0 ? "done" : inner(i-1);
    function tail_caller(i)    = inner(i);          // body hits 0
    function nontail_caller(i) = str("", inner(i)); // body hits 1

The hit goes in recordTailCallHop rather than at the Op::CallFnTail call
site, next to the noteActiveDeclExit/Enter pair that is already there for
exactly the same reason: a hop swaps which declaration occupies the frame
and every piece of per-declaration bookkeeping has to follow it.

Coverage.VmAndInterpreterAgree would have caught this if anything in it
had recursed in tail position; the new test does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb
@revarbat
revarbat merged commit 853d893 into main Sep 12, 2026
3 checks passed
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