← board

Too few arguments to a container method compiles, then segfaults

Measured (self-hosted binary at b78988fe8)

call expected pxx
[1,2,3].index() TypeError (missing argument) SIGSEGV, core dumped
{"a":1}.get() TypeError SIGSEGV, core dumped
[1,2,3].count() TypeError compiles, returns 3 (wrong, but no crash)
"abc".find() TypeError compile error — correct
"a,b".split(",",1,2,3) TypeError compile error — correct

Two contrasts pin it down:

  1. Too MANY arguments is rejected; too FEW is not. So an arity check exists and only fails in one direction.
  2. str methods are rejected; container methods are not. str methods go through their own table (PyParseStrMethod and the str-method table in compiler/pyparser.inc), which validates. TPyList/TPyDict/TPyBytes methods resolve as ordinary Pascal method calls and evidently do not.

Impact

Every pylib container method taking arguments is a latent crash if called bare, and the failure mode is the worst kind: it COMPILES, so there is no diagnostic pointing at the call site, and the crash address is inside pylib. A typo or a half-finished edit (d.get() while reaching for d.get(k)) becomes a core dump instead of a compile error.

.count() is the quietly worse case — no crash, just a wrong value from reading an uninitialised parameter, which is this repo's expensive failure shape.

Cause (to determine — do NOT guess)

Unknown. The decode instance was a Pascal overload set where the zero-arg form did not exist and the call bound to the one-arg form regardless, leaving the parameter uninitialised. Whether the general case is the same overload-resolution path, or a missing arity check on the NilPy method-call lowering, is NOT established. .count() returning a plausible value while .index() crashes suggests the argument slot is simply left as garbage rather than the call being rejected, but that is a hypothesis.

Start by comparing the lowering of xs.index() against xs.index(2) with PXXDBG=a.ir:<proc> (wrap in a def — the module-level dump prints nothing), and check whether FindUMethArity (compiler/symtab.inc) is consulted on this path at all.

Fix shape

Reject a call with fewer arguments than the resolved method's required parameter count, as a compile error (this is a static arity question, unlike the operand-type cases which must be runtime TypeErrors to stay catchable). FindUMethArity already exists and is the natural check.

Where a Python method genuinely has an optional argument, the pylib method needs a real overload for the shorter form — as TPyBytes.decode now has. Audit the container classes for others: any overload set whose shortest form still takes a parameter Python treats as optional has this bug.

Gate

make test-nilpy + self-host byte-identical, plus a .npy asserting a compile error (or catchable TypeError, per whichever the fix chooses) for each of index(), get(), count() called bare, and confirming the correct-arity calls are unchanged.

2026-08-01 — PARTIALLY fixed; two framing corrections; the COMMON case remains

Measured on a self-hosted binary at 2a2b478e6 + the change below. Landing the covered half because it turns real crashes into diagnostics, but the ticket stays open: the shape people actually write is still broken.

Correction 1: too MANY arguments is NOT rejected either

The ticket's contrast — "an arity check exists and only fails in one direction" — is a measurement artifact. It compared "a,b".split(",",1,2,3) (a str method, own validating table) against a container method. Measured directly:

call result
[1,2,3].index(1,2,3,4) SIGSEGV (not "rejected")
[1,2,3].count(1,2,3) SIGSEGV
{'a':1}.get(1,2,3,4) SIGSEGV

So container methods have no arity check in either direction. Only str methods validate. Do not build on the "one direction" framing.

Correction 2: it is not container-specific — user classes crash too

class C:
    def m(self, a):
        return 99      # never reads `a`
c = C()
print(c.m())           # SIGSEGV

return 99 never touches the parameter, so the crash is the CALL FRAME, not a garbage read in the body. Any NilPy method call with wrong arity is affected, not just pylib containers.

Root cause

FindUMethArity falls back to the plain first-name-match when no overload accepts the count (symtab.inc, last line of the function — deliberate, and other callers rely on it). So every caller's if k >= 0 then test can never reject, and the default-filling loops that follow only fill parameters that HAVE defaults — a missing REQUIRED argument is simply left unfilled.

Fixed here

Arity validated at the call site (not by tightening FindUMethArity, whose fallback is relied on elsewhere), reusing ProcArityMatches, exempting *args/**kwargs:

NOT fixed — and this is the common case

A NAME receiver still segfaults, which is how the code is normally written:

xs = [1, 2, 3]
xs.index()          # STILL SIGSEGV
c = C(); c.m()      # STILL SIGSEGV

Measured: neither PyParseClassMethodCall nor PyParseVariantMethod is entered for these (gdb breakpoints on both, never hit). FindUMeth(ci=0, 'm') IS called, so the class is known — the call is resolved by the shared Pascal parser (compiler/parser.inc, the FindUMethArity sites at ~3307 / 3363 / 3415 and the method-call routes near 4724 / 4812), not by the NilPy paths.

Note plain Pascal arity IS enforced there — f.Bar(1) against Bar(a, b: Integer) errors with "Expected: ,". So the relaxation is specific to NilPy mode (PyExprMode), and that is where to look next. That is shared Track A ground (parser.inc), so it needs the sole-A guard, and it is why this was not extended in the same pass.

Next step

Start at parser.inc's NilPy method-call route and find where the argument list is parsed without checking the callee's parameter count — Pascal's own path enforces it by parsing exactly ParamCount arguments, so the question is what NilPy mode does instead. Measure which route builds the AN_CALL for c.m() (a probe is more reliable here than a gdb condition — several $_streq conditions misfired during this investigation).

Also worth deciding

Compile error vs catchable runtime TypeError. CPython raises TypeError at RUN time, and this repo deliberately moved missing-dunder and list+non-list from compile errors to runtime TypeErrors for exactly that reason (b1f5b0e0b, eeae1e4a3). The str-method table, by contrast, still compile-errors. This fix follows the str-method precedent because a compile error is strictly better than a segfault and far simpler — but the end state is arguably a runtime TypeError, and that is a Track U call rather than something to settle in passing.

2026-08-04 — the NAME-receiver case is fixed; ticket closes

The previous entry's next step was right about WHERE (parser.inc, the shared method-call route) and the cause turned out to be smaller than "NilPy mode relaxes the arity loop".

Root cause

The obj.F() empty-parens shortcut:

if CurTok.Kind = tkRParen then
begin
  if (Procs[mpi].ParamCount > 1) and CanFillDefaultsFrom(mpi, 1) then
    FillDefaultArgs(mpi, 1, mcallNode, mlastArg);
  mai := Procs[mpi].ParamCount;   { <-- unconditional }
end;

It exists for a method whose parameters ALL have defaults (J.FormatJSON()), but it sets mai past the arity loop whether or not the defaults could actually be filled. So with required parameters the loop never ran, the slots were never filled, and the callee read an uninitialised frame. Nothing about PyExprMode was involved — which is why the earlier hunt for a NilPy-specific relaxation found nothing.

Correction 3: it is not NilPy-specific at all

The previous entry concluded "the relaxation is specific to NilPy mode (PyExprMode), and that is where to look next", reasoning from f.Bar(1) against Bar(a, b) being rejected in Pascal. That comparison used a NON-empty argument list, which takes the arity loop and fails at Expect(tkComma). Empty parens skip the loop in Pascal too:

type TC = class function M(a: Integer): Integer; end;
c.M()   { accepted; FPC rejects it }

So this was a Pascal bug as well, and the fix is in the shared route for both. Same lesson as the abs() mis-filing earlier today: the control has to differ in exactly one thing.

Fixed

Both identical copies of the shortcut now error when the parameters cannot be filled from defaults, exempting *args/**kwargs:

pascal26:5: error: m() requires 1 argument(s), none given
pascal26:2: error: index() requires 1 argument(s), none given
pascal26:11: error: M() requires 1 argument(s), none given   { the Pascal case }

Compile error rather than a runtime TypeError, following the str-method table precedent and the earlier half of this fix. The "arguably the end state is a runtime TypeError" note below stands as a separate Track U question; a diagnostic is strictly better than a segfault either way and does not foreclose it.

Gate

tools/gate.sh quick + make test green — the full Pascal suite was run deliberately, because this tightening turns previously-COMPILING code into a compile error on shared ground, so the risk is library or corpus code that relied on the laxness. Nothing did.

Tests: test_nilpy_method_arity_missing_args_fails.npy (expects the diagnostic) and test_nilpy_method_arity_ok.npy (no-arg method, correct-arity call, omitted defaulted parameter, and the xs.index() / d.get() container methods that were the original crash reports).

Log