← board

def f(s): return s[0] SEGFAULTS — indexing an unannotated str parameter

def f(s):
    return s[0]
print(f("ab"))        # CPython: a     pxx: SIGSEGV

An unannotated parameter is the default way to write Python, and indexing a string is one of the most common things done with one.

Scope — measured

shape pxx
def f(s): return s[0] with a str argument SIGSEGV
def f(s): return s[0:1] (slice) with a str argument [] — silently wrong
def f(s: str): ... s[0] ... (ANNOTATED) correct
s = "ab"; s[0] at top level correct
def f(xs): return xs[0] with a LIST correct
def f(d): return d["k"] with a DICT correct
def f(s): then for c in s: correct

So it is specifically a STRING reaching a variant-typed subscript. Found via a palindrome function (if s[i] != s[j]), which crashed outright.

Root cause

pylib.pas, pyvar_getitem:

function pyvar_getitem(const v: Variant; const key: Variant): Variant;
var o: TObject; ki: Int64;
begin
  o := TObject(pyvarobj(v));        { <-- cast BEFORE any tag check }
  if o is TPyDict then ...

The tag is never examined. For a variant holding a string (VT_STRING, tag 6) pyvarobj hands back the string's handle, and the is TPyDict test then dereferences a VMT pointer out of string bytes.

Its sibling pyvar_slice, ten lines below, gets this right:

  if pyvartag(v) = 6 then
    Result := pystr_slice(pystr_of(v), lo, hi)     { str -> boxes to VT_STRING }
  else if pyvartag(v) = 7 then
  ...

Same shape as the recurring pattern in this repo: one ownership/identity predicate written inline in two places, and one place gains a case the other lacks. pyvar_getitem needs the tag-6 arm (and tag 5, a char), and must not cast at all when the tag is not 7.

The slice returning [] rather than "a" is a second, separate defect on the same path — pyvar_slice DOES handle tag 6, so the wrong answer comes from further up (probably the frontend choosing a list-slice lowering for a variant-typed receiver). Re-measure it after the getitem fix rather than assuming they share a cause.

Not a regression

Reproduced identically on the stable compiler from 2026-07-27, before this session's work.

Gate

make test-nilpy + self-host byte-identical, plus a .npy covering the table above with CPython's own output, and the palindrome function end to end. Keep the list, dict, annotated and top-level rows as guards that the working paths are untouched.

Log