Skip to content

A bulleted docstring moved the reported frame line - #656

Merged
jgfoster merged 1 commit into
mainfrom
fix/frame-line-derivation
Aug 24, 2026
Merged

A bulleted docstring moved the reported frame line#656
jgfoster merged 1 commit into
mainfrom
fix/frame-line-derivation

Conversation

@jgfoster

Copy link
Copy Markdown
Member

Continues the frame-walk work after #653. That PR fixed a transient source-read fault; this fixes a deterministic wrong-line bug in the same machinery, found by re-measuring the reproduction from #651 against post-#653 main.

The bug

___derivePythonLineForMethod___:ip: (and its span companion) find the Python line by locating the caret GsNMethod >> _sourceAtIp: inserts, then taking the last ___curPos___ := N at or above it. The caret is marked

 * ^1                                                            *******

and both scans identified it as "the first line whose first non-blank character is an asterisk".

That is not sufficient — because a Python docstring is emitted as a multi-line Smalltalk string literal, so its own lines land in the generated source verbatim:

___curPos___ := #(5 4 5 5 '    """Summary line.').
'Summary line.

    * first bullet
    * second bullet
    '.
___curPos___ := #(11 8 nil 8 '    a = 1').

A bullet is indistinguishable from the marker, the scan takes the first match, so a bullet above the real caret wins and the caret is located too early. The line then comes from higher up the function.

def bulleted_docstring():
    """Summary line.

    * first bullet
    """
    a = 1
    return sys._getframe().f_lineno     # CPython: 46,  Grail: 40

___isCaretLine___ now requires ^ followed by a digit after the asterisk — the marker always carries the ip, prose does not.

Comments are not affected, and that asymmetry is why this took a docstring to find. A Python comment never reaches the generated Smalltalk; only the one-line source excerpt inside each position literal does. Grail's own hand-written Smalltalk comments use the same bullet style — 11 of 1060 probed methods have such a line — so this is ordinary input, not contrived.

Why it belongs to the flake family

This is the deterministic half of the intermittent frame-walk failures. When the misplaced caret lands above every ___curPos___, the scan answers nil rather than a wrong number — and a nil drops the frame, which is exactly how sys._getframe() comes to raise ValueError: call stack is not deep enough. One cause, two symptoms, which is part of why the family looked like two unrelated bugs.

It does not close the intermittent failures — and here is the new evidence

Measured with GRAIL_TEST_SHARDS="0 1" ./scripts/run_tests.sh, the whole-suite reproduction from #651:

tree runs failures
main before #653 5 3
main after #653 5 1 (GeneratorStackFrameTestCase)
this branch 5 1 (my own new test)

So #653 helped materially and something remains.

The residual bug now has a much sharper probe. The new test asserts exact line numbers and reports the wrong value, where the older tests only report that something differed. When it flaked:

got (46, 108) want (46, 77)

The inner frame is right; the outer frame reported 108. The fixture file is 100 lines long — so 108 is not a line in it at all, which rules out a mis-scan of that method's own source, caret or otherwise, and says the number came from somewhere else.

Two candidates, written up in docs/Issues.md, neither confirmed:

  • A stale cache entry. Five session caches key on aMethod asOop with no liveness guarantee (GrailIpLineCache, GrailIpSpanCache, GrailFnNameCache, GrailSoleFnNameCache, GrailPyMethodCache); a recycled OOP would answer from an unrelated method, which fits "a line in an unrelated function" exactly. But I measured against it: 25 generations of loading and dropping the same fixture module produced 0 OOP collisions. Not demonstrated, so not claimed.
  • A mis-parsed position literal. ___parsePositionLiteral___:from:' own comment records a previous bug of this exact shape (concatenating a line with an adjacent literal to derive 37133718). 108 is what #(10 8 ...) yields if the digit scan fails to stop at the space.

One thing for you to decide

My new test flakes at the same rate as the family it probes (1 in 5 under the shard split). I kept it whole rather than trimming the nested-frame assertion, because it is a correct expectation and trimming it to get a green run is the same move as widening a timing margin to suit one. It does not make CI measurably flakier — the family already failed at that rate — and when it does fire it now names the wrong value instead of shrugging.

Say the word and I will drop the inner_caller row to the documented-issue section instead.

Gates — tier 2

BaseException.gs is on every raise and every traceback.

gate result
CPython suite 0 regressions, 0 improvements — scoreboard byte-identical
SUnit 5670 / 5670, 0 failed, 0 errors
fixture gate 207 files, 2728 OK, 34 XFAIL

Coordination

Another session has been investigating this family in parallel and has the deeper frame_depth.py evidence plus a faster repro (four concurrent sessions looping one class, ~12%/iteration, no suite). This PR deliberately does not touch what they are working on — it fixes the one deterministic defect I could prove, and hands over the sharper probe.

`___derivePythonLineForMethod___:ip:` and its span companion find the Python
line by locating the caret `GsNMethod >> _sourceAtIp:` inserts, then taking the
last ``___curPos___ := N'' at or above it.  The caret is marked

     * ^1                                                          *******

and both scans identified it as "the first line whose first non-blank character
is an asterisk".

That is not sufficient.  A Python DOCSTRING is emitted as a MULTI-LINE Smalltalk
string literal, so its own lines land in the generated source verbatim, and a
bullet list in one is indistinguishable from the marker:

    ___curPos___ := #(5 4 5 5 '    """Summary line.').
    'Summary line.

        * first bullet
        '.
    ___curPos___ := #(11 8 nil 8 '    a = 1').

Since the scan takes the FIRST match, a bullet ABOVE the real caret wins, the
caret is located too early, and the line reported comes from higher up the
function.  Measured deterministically: a four-line function with a bulleted
docstring reported 40 where CPython reports 46.

___isCaretLine___ now requires ``^'' followed by a digit after the asterisk --
the marker always carries the ip, and prose does not.

COMMENTS ARE NOT AFFECTED, and that asymmetry is why this took a docstring to
find: a Python comment never reaches the generated Smalltalk, while a string
literal does.  Grail's own hand-written Smalltalk comments use the same bullet
style -- 11 of 1060 probed methods have such a line -- so this is ordinary
input rather than a contrived one.

It is the DETERMINISTIC half of the intermittent frame-walk family: when the
misplaced caret lands above EVERY ___curPos___ the scan answers nil rather than
a wrong number, and a nil DROPS the frame, which is how sys._getframe comes to
raise ``call stack is not deep enough''.

Pinned by tests/python/frame_line_bulleted_docstring.py -- 5 checks, expected
values CPython 3.14.6's, of which 3 flip when the fix is reverted; the 2 that
hold are the controls (a plain docstring, and a bulleted COMMENT).  Driven from
TracebackTestCase rather than a new class on purpose: a new class would move the
class->shard mapping, and this family is sensitive to which classes share a
session.

It does NOT close the intermittent failures.  docs/Issues.md records what the new
test saw when it flaked -- ``got (46, 108) want (46, 77)'', where the fixture is
100 lines long, so 108 is not a line in it at all -- together with the two
candidate mechanisms and the measurement that argues against the first of them.

Gates (tier 2 -- BaseException.gs is on every raise and every traceback):
  * CPython suite: 0 regressions, 0 improvements; scoreboard byte-identical.
  * SUnit: 5670 / 5670, 0 failed, 0 errors.
  * fixture gate: 207 files, 2728 OK, 34 XFAIL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jgfoster
jgfoster enabled auto-merge August 24, 2026 23:18
@jgfoster
jgfoster added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 5482e0a Aug 24, 2026
5 checks passed
@jgfoster
jgfoster deleted the fix/frame-line-derivation branch August 24, 2026 23:35
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