← board

A class used as a VALUE: SEGFAULT from a container, compile errors from a name

Python treats a class as an ordinary object — you can bind it, put it in a list or dict, pass it, and call it. That is how registries and factories are written, and every shape of it is broken here. One of them segfaults.

The segfault — a class reached through a container

class A:
    def __init__(self, v):
        self.v = v

for cls in [A]:
    x = cls(3)          # SEGFAULT
    print(x.v)

Compiles clean (ok:), then dies with SIGSEGV. Same with an exception class:

class A(Exception):
    pass

for cls in [A]:
    try:
        raise cls("x")   # SEGFAULT
    except Exception as e:
        print(type(e).__name__)

Printing the loop variable shows what it is: print(cls) emits a bare integer (5781693) where CPython prints <class '__main__.A'>. So the class is travelling through the container as a raw number and is then CALLED as if it were a constructor.

A segfault from ordinary, valid Python is the worst outcome available — worse than the compile errors below, which at least name themselves. If the full feature is too big to land at once, making this shape a NAMED REFUSAL is a complete and worthwhile fix on its own.

The compile errors — a class bound to a name

cls = A
x = cls(3)
print(x.v)      # error: "v": a pointer has no members

The call is accepted but its result is typed pointer, so the very next member access fails. The instance is real; only its type is lost.

cls = A
raise cls("x")  # error: Nil Python: expected newline after statement

raise does not accept a non-literal class at all.

for cls in [A, B]:
    print(cls(1).v)   # error: Expected: ), but got: (Kind: 81)

Confirmed pre-existing

Both the segfault and the pointer has no members error reproduce with stable_linux_amd64/default/pinned. Not a regression from this session's work.

Notes toward a fix

pxx already has a runtime metaclass — AN_CLASSREF lowers to the class's RTTI blob address, BuildMetaclassNew constructs through it, and the Pascal side uses this for the fpcunit Suite idiom, so construction through a class reference is a solved problem. What is missing is treating the classref as a value: giving it a type that survives being stored in a variant container, recovering that type on the way out, and routing cls(...) and raise cls(...) through BuildMetaclassNew instead of through the ordinary call path.

print(cls) showing a bare integer is the direct evidence that the value is currently untagged — a variant holding a class reference needs its own tag, the way callables needed one (see the three-representations note on Callable).

Gate

make test-nilpy + self-host byte-identical, with a CPython-diffed test over: a class bound to a name and called; a list of classes iterated and called; a dict of name→class; a class passed as an argument and returned; raise cls(m) in both spellings; type(x).__name__ on the result; and print(cls) / cls.__name__. If the scope is narrowed to a refusal, the test asserts the diagnostic instead and this ticket stays open for the feature.

Recon 2026-08-09 — located to the line, BLOCKED on the sole-A guard

A bare class name used as a value is built at compiler/parser.inc:4409:

{ Class identifier used as a value (metaclass / class reference) }
node := AllocNode(AN_CLASSREF);
ASTIVal[node] := ci;
ASTTk[node] := Ord(tyPointer);

That is the shared expression parser — a Track A file under the sole-A guard — so neither the full fix nor the interim NAMED REFUSAL can be written from an unattended Track N session. Same block as the four tickets already behind decide-sole-a-guard-for-unattended-sessions; marked blocked-by so the queue stops offering it.

What the refusal cannot be: a blanket "class name as a value" error. The same node is what isinstance(x, A) and except SomeError: produce, and both work today. The refusal has to be narrower — the classref being stored into a variable or a container element, i.e. where it becomes an untagged integer — and that distinction is exactly why it belongs with someone who can see the whole expression path rather than being bolted on.

Also measured, and it constrains the eventual fix: the runtime cannot recover here either. pyvar_callv0..3 guard only Payload = 0, and a plain compiled def is its code address boxed as a plain integer, so a class reference and a callable are indistinguishable by tag — see bug-nilpy-calling-a-non-callable-segfaults for the measurement and for the guard I built, found inert, and reverted. Both tickets therefore want the same thing: a distinct callable/classref variant tag.

2026-08-09 — the SEGFAULT is gone: refused by name (sole-A confirmed)

The ticket said a named refusal is a complete and worthwhile fix on its own, and that is what landed. parser.inc's class-identifier-as-a-value site now errors in PyExprMode, naming the class and the workaround, instead of emitting an untagged RTTI-blob address that becomes an ordinary integer in a variant.

Measured before refusing, because a blanket refusal would have been wrong. An Error probe at that exact site showed which shapes reach it in PyExprMode:

shape reaches the site?
isinstance(x, A) no — own intercept
except A: no — own intercept
A(...) construction no — own intercept
c = A yes
for c in [A] yes

So in NilPy this site is reached ONLY by the broken shapes, and refusing costs none of the working ones. That measurement is what made a two-line refusal safe; guessing at contexts would not have been.

The three programs that segfaulted now stop at compile time with a diagnostic, and every class test in the suite still compiles and matches.

Still OPEN — this ticket stays open for the FEATURE. Supporting a class reference for real needs a distinct variant tag, which is the same thing bug-nilpy-calling-a-non-callable-segfaults needs (a def's code address is likewise boxed as a plain integer, so a callable and a class reference are indistinguishable by tag). When that lands, retire test/test_nilpy_class_as_value_fail.npy — making the shape merely PARSE without making it correct would resurrect the segfault.

2026-08-09, Track A+N — the recorded plan does not work; escalated to Track U

This ticket (and the two below it) concludes that what is needed is a distinct variant tag. That is necessary and not sufficient, which had not been measured. Measured at HEAD 3e7a6b792 with PXXDBG=n.ctorargs:

class A:
    def __init__(self, v): self.v = v      # A trial=0 [0]kind=1,tk=1   INTEGER
class B:
    def __init__(self, s): self.s = s      # B trial=0 [0]kind=2,tk=4   ANSISTRING

NilPy ctor parameters are statically INFERRED per class from the call sites, so two classes of the same arity have different ABIs. handlers = {"a": A, "b": B} then handlers[k](x) cannot go through one code pointer however well the value is tagged. A tag lets you RECOVER the class; it does nothing about CALLING it.

Same reason AN_METACLASS_NEW does not generalise: it reads instance size and VMT from the RTTI blob at run time (dynamic class), but lowers its arguments via IRLowerCallArg(cpi, ...) against a statically known ctor proc index (static signature). That split is the whole problem in one line.

So the fix is a design fork, not an implementation detail — filed as [[decide-nilpy-class-as-value-dispatch-strategy]] with the three options (compile-time candidate dispatch / RTTI-driven runtime marshaller / uniform variant ctor ABI), their costs, and a recommendation. This ticket stays open and correctly blocked-by that decision rather than being half-built in a direction that may be wrong.

The named refusal already landed, so the segfault stays gone in the meantime.

Resolution (2026-08-11)

Closed by [[feature-nilpy-class-as-a-value]] plus two follow-ups found by walking this ticket's own list of shapes. Every row it names now matches CPython, except one, which turned out to be a different bug (below).

this ticket's shape now
for cls in [A]: cls(3) — the SEGFAULT works
cls = A; cls(3).v — "a pointer has no members" works
for cls in [A, B]: cls(1).v works
print(cls) printing a bare integer <class '__main__.A'>
a class passed as an ARGUMENT works
a class RETURNED from a def works — fixed here
raise cls("x") a different bug — see below

The two follow-ups

A class RETURNED from a def (def gives(): return B). The feature made the expression a VT_CLASSREF variant, but PyInferDefRetTypeScan still typed a bare class-name return as tyClass — so the caller read a variant as an object pointer and died at the first use, not at the return. A bare class name with no ( is the value form and now infers tyVariant; return B(...) is construction and keeps tyClass. The name-shadow clear that sits just above it already handles return b where the def binds b, so reaching the new arm with tyClass means a genuine class reference.

An EXCEPTION subclass as a value (ke = Err; ke("boom")). class Err(Exception): pass has no __init__ of its own and is constructed through the inherited Exception.Create(const msg: AnsiString) — a REAL signature, not a widening the frontend forgot, so PyClassRefNew marshals it as its own shape rather than tripping the not-widened guard. That guard now also names the kind and position it could not marshal instead of blaming the compiler.

The one that is NOT this ticket

raise cls("x") still segfaults — but the construction is fine now and the crash moved to the raise. Isolated:

xs = [E("a")]
raise xs[0]          # SEGFAULT
e = E("a"); raise e  # works
raise E("a")         # works

So it is raise with a VARIANT operand, unrelated to classes-as-values and pre-existing at pinned. Filed as [[bug-nilpy-raising-a-variant-segfaults]] with the boundary measured, rather than held open here — this ticket is about the class value, and the class value works.

Also still open, and its own ticket's business: A.__name__ on a class NAME is "class method not found" — [[bug-n-a-type-name-is-not-a-first-class-value]]. type(x).__name__ works.

Verified

test/test_nilpy_class_as_a_value.npy grew a block per row of the table above, .expected regenerated from CPython. Gate: tools/gate.sh quick GREEN + make test-nilpy.

Log