← board

A Python override of a virtual Pascal method segfaults on the call back

The repro, and its negative control

# b8e_cfg.py -- SEGFAULTS under pxx, runs clean under CPython
import configparser

class CasePreserving(configparser.ConfigParser):
    def optionxform(self, optionstr):
        return optionstr

cfg = CasePreserving()
cfg.read('probe.ini')          # probe.ini: "[Main]\nAlpha = 1\nBeta = 2\n"
print('read ok')

Control — the same program with the subclass removed, reading the same file, succeeds. That is what isolates the variable to the override rather than to read, to the file, or to the mimic:

import configparser
cfg = configparser.ConfigParser()
cfg.read('probe.ini')
print('read ok')               # -> "read ok", rc=0

pxx  subclass:    SIGSEGV (rc=139), 3/3 runs
pxx  no subclass: read ok,  rc=0
cpython both:     read ok,  rc=0

Measured 2026-09-21 against pin v414, binary sha256 aeadb1754b80, x86-64.

The .ini must have a section header. My first reduction used a sectionless file; CPython raises MissingSectionHeaderError and optionxform is never reached, so the repro "passed" for the wrong reason. Stated because the next person will write the same fixture.

THE MECHANISM (measured 2026-09-21, this is the cause, not the symptom)

=> 0x5529fd <C.optionxform+260>:  rep movsb (%rsi),(%rdi)
   rsi 0x7fffffffc5f0   (valid)
   rdi 0x0              <-- 16-byte result written to NULL
   rcx 0x10

A Python method overriding a Pascal virtual goes into the vtable slot with no ABI adapter. The nilpy body returns a 16-byte Python value through a hidden destination pointer in %rdi. The Pascal slot's signature is function optionxform(const s: AnsiString): AnsiString8 bytes in %rax, no hidden pointer passed. So %rdi holds whatever it held, here 0, and the body copies 16 bytes to address zero.

THIS EXPLAINS WHY THE CRASH DEPENDS ON THE BODY, which is what makes the bug look non-deterministic when it is not. A body whose result the compiler can materialise directly never takes the copy path; one whose result must be copied out of a stack slot does. Deterministic 3/3 in both directions:

override body result
return optionstr SEGV
s = optionstr; return s SEGV
return optionstr[:] SEGV
return 7 SEGV
return optionstr.upper().lower() SEGV
return "fixed" ok
return optionstr.lower() ok
return optionstr + "" ok
return str(optionstr) ok
pass ok

Note rows 5 and 7: optionstr.upper().lower() and optionstr.lower() produce the IDENTICAL string and land on opposite sides. So the fault is not in the value and not in aliasing the parameter — it is in which return path the body compiles to. Any story that explains this by what is returned is wrong; I had two such stories before reading the registers.

A TRAP I FELL INTO, recorded because the disassembly invites it. The faulting block reads lea -0x20(%rbp),%esi — a 32-bit destination, which looks exactly like an address-truncation bug, and I had it written down as the cause. The registers say otherwise: %rsi is a correct 64-bit address and %rdi is null. Reading the disassembly instead of the registers produced a confident, precise, wrong mechanism. Check the registers.

What a fix looks like (not attempted — see scope)

An adapter thunk at the seam: a generated shim with the Pascal signature that calls the nilpy body and moves the result between the two conventions. There is precedent in the same file — PyGetOrMakeCloneThunk (pyparser.inc:17514) already synthesises $pyclonethunk_N for the clone trampoline — so the machinery pattern exists and is not being applied here.

This is a project, not a patch, which is why it is banked with the mechanism named rather than half-fixed: it needs a decision about which conventions the seam must bridge in general (result width, self, exceptions crossing back), not just this one return.

Where it faults

Rebuilt -g -O2, the location is exact and it is INSIDE the Python body:

Program received signal SIGSEGV
#0  CasePreservingConfigParser.optionxform
      (self=0x7fffd74010e0, optionstr=0x7fffd74474e8 'WindowSize')
      at settings.py:60
60          return optionstr
#1  0x0000000000565ded in ?? ()

So the dispatch works — the right override is entered, with a live self and a correct optionstr. The fault is on the return, and the caller frame (Pascal, inside ConfigParser.get/set, which call optionxform(option) at configparser.pas:154/166/182) has no symbols. Whoever takes this should start at how a nilpy method body returns an AnsiString to a Pascal caller that reached it through a vtable slot, not at configparser.

Why the corpus cannot see it

The crossing is Pascal code making a virtual call that lands in a nilpy method body. A nilpy program overriding a nilpy class is fine; a Pascal program overriding a Pascal class is fine. Only the mixed direction faults, and it is reachable only from a .npy program that subclasses an RTL class — which no fixture does.

Why it is not scoped to configparser

lib/rtl/configparser.pas:17-25 marks optionxform virtual and says why:

optionxform is VIRTUAL because that is the whole reason real code subclasses ConfigParser ... Python methods are always virtual; Pascal's are not, so without virtual the override would compile and silently never run.

The author anticipated this exact pattern, quoted songformatter's own class verbatim, and guarded against the silent-no-op failure. Every virtual in a lib/rtl unit that a Python program might override is the same shape, and the RTL marks methods virtual precisely when it expects an override — so the population is not one method, it is the set the RTL has deliberately opened.

NOT a bug, recorded so it is not refiled

The same probing showed pxx accepting a sectionless .ini where CPython raises MissingSectionHeaderError. NilPy is UPWARD compatible with CPython, one direction — accepting what CPython rejects is a feature, not a defect.

What it blocks

feature-demo-songformatter-pxx-target (p68). settings.py from the real app compiles under pin v414 with two unrelated warnings and then segfaults at module level on cfg.read(CONFIG_FILE), deterministically, while CPython runs the same file clean. Three of the app's five modules build and two of those run; this is the only crash in the set, the other failure being a missing PIL binding.

Positive control when a fix lands, both arms

The repro above must run and print read ok, and the no-subclass control must still do so — a fix that stops the Pascal side calling the override at all would silence the crash and reintroduce exactly the silent-no-op failure the unit header was written to prevent. Assert that the override's value is used: read back an option whose case is preserved (Alpha, not alpha), which no default optionxform can produce.