← board

d.update(a=1, b=2) segfaults — the method half of keywords-are-KEYS

What was measured

PyBuildKeywordDict builds the dict a keyword run describes — hoisted temp, one setitem per pair, pydict_merge for a ** spread, exactly as a {...} literal is built — and the caller passes it as the single dict argument both dict() and update() already take. With that builder wired to BOTH callees:

shape result
dict(z=7, y=8) correct
d.update({"z": 7, "y": 8}) correct
d.update(z=6) correct
d.update(z=7, y=8) SEGFAULT

The dict it builds is right (row 1) and the dict it is handed is right (row 2), so the fault is in what the METHOD-argument path does with the builder's hoisted statements. Prime suspect, unconfirmed: a trial parse that rewinds and replays them (PyHoistPark / PyHoistRestore / PyHoistMerge) — the shape recorded in devdocs/dev/ and in several sibling tickets. One keyword hides it because replaying one idempotent setitem changes nothing.

The ** form is refused somewhere unexpected — read this before hunting

g.update(**src) reports expected expression, from ParseFactor's final else (i.e. something called ParseFactor with * as the current token). That error does not come from any of parser.inc's five method-argument loops. All five were instrumented — a Warn in PyKeywordsAreKeys printing the callee name every time it is asked — and while compiling that one line not one of them is reached: the last probe fires deep inside pylib, then the error.

So the route that handles recv.method(**expr) is none of: the arity-driven method loop, the field-receiver loop, the plain class-method loop, the metaclass-ctor loop, the plain-call loop. Find it first. Patching loops one at a time cost four rebuilds and found nothing.

(The plain keyword form d.update(z=6) DOES reach the arity-driven loop. Same construct, two routes — devdocs/dev/normalise-dont-special-case.md.)

Gate

d.update(a=1), d.update(a=1, b=2), d.update(**e) and d.update(other, a=1) (CPython allows the mixed form; today's builder does not and says so) all diffed against CPython, plus the existing test/test_nilpy_dict_keyword_args.npy rows staying green.

2026-08-13 — the ENABLING CONDITION found, and the segfault reproduced behind it

Nothing shipped; this is the diagnosis, banked. Two facts, both measured.

1. PyKeywordsAreKeys could never have matched a METHOD

It compares Procs[mpi].Name against 'dict'. A method's Procs entry is named qualifiedTPyDict.update — so the predicate answered False for every method, whatever the call site did. That is why the earlier session's instrumentation "was not reached by any of the five argument loops": the loops were fine, the predicate said no before any of them mattered.

Measured with a one-shot probe (a PyKwSite global set at each of the nine PyKwArgIndex call sites and printed in its error): d.update(z=6) reaches parser.inc's arity-driven method loop, which has consulted PyKwDictArgNode all along. So there was never a missing call site — only a name comparison that could not succeed.

2. With the name fixed, the reported symptom reproduces exactly

CaseEqual(Procs[mpi].Name, 'TPyDict.update'):

shape result
d.update(z=6) correct
d.update(z=7) onto an existing z correct
d.update(z=7, y=8) SEGFAULT
d.update(z=7, y=8, x=9) SEGFAULT
d.update({"z": 7, "y": 8}) correct
dict(z=7, y=8) correct

So the ticket's headline is confirmed rather than explained: one keyword is fine, two corrupt memory, and the SAME builder feeding dict(...) in the same program is correct with two.

The faulting instruction is mov (%rax),%rax after rax := ptr - 8 — a length/refcount word read through a bad handle, the shape of a use-after-free, inside pylib (no DWARF there).

Ruled out while here

The obvious suspect — the hoisted setitem statement being typed tyClass, so each hoisted statement releases the returned Self and two releases free the dict — is not it, or not it alone: PyParseDictLiteral builds {"z": 7, "y": 8} with an identically-typed, identically-hoisted setitem per pair, in the same argument position, and is correct.

What to try next

Diff the two hoist SEQUENCES rather than the builders — dump PXXDBG a.ir for d.update({...}) and for the keyword form with the predicate re-enabled, and compare where the temp is assigned relative to the receiver's evaluation. The one structural difference left is that the keyword form's hoists are queued while the method's argument list is being parsed and the literal's are queued while its own literal is, which is the trial-parse/hoist-queue interaction [[project_trial_parse_rewind_leaves_its_hoists_queued]] describes.

Reverted rather than shipped: a form correct with one keyword and corrupting memory with two is worse than the compile error it replaces, which is the same call the earlier session made.

RESOLVED 2026-08-14 — already fixed by its own sibling; verified, not assumed

Both headline symptoms are gone at HEAD, and neither was fixed by this ticket: [[bug-nilpy-small-builtin-surface-gaps-found-by-the-2026-08-13-sweep]] landed PyDictKwOverloadAhead (compiler/pyparser.inc), and its comment names exactly the two failures described above.

Measured at HEAD, diffed against CPython:

d.update(z=6)        -> {'z': 6}                 correct
d.update(z=7, y=8)   -> {'z': 7, 'y': 8}         correct (was a SEGFAULT)
g.update(**e)        -> {'q': 1, 'r': 2}         correct (was "expected expression")

PyKeywordsAreKeys is no longer gated to dict — it answers True for TPyDict.update too, by PROC INDEX against every overload — so the lowering is shipped, not merely built. test_nilpy_dict_update_keywords.{npy,expected} covers one keyword, two, three, a ** spread, a spread plus an explicit key, and a method-call receiver; it is green.

The cause, since this ticket guessed wrong about it

The suspicion recorded above was a trial parse replaying hoisted statements (PyHoistPark/Restore/Merge). It was not. The argument list was described to overload selection in the wrong UNITS: keywords-are-KEYS means the whole keyword run is ONE TPyDict argument however many keywords it holds, but the arity filter COUNTED the keywords — so update(z=7, y=8) looked like a two-argument call, matched none of the three arms, and fell through to FindUMethArity's first name hit, the update(l: TPyList) arm, which received a TPyDict and died. update(z=6) happened to land on the Variant arm and worked, which is what made it look like a replay bug ("one keyword hides it"). The ** refusal was the same cause seen from the speculative probe's side.

Worth keeping: "one keyword works and two segfault" reads like a repetition bug and is not one. The argument-count asymmetry was the tell that the call was being COUNTED wrongly, not replayed.

Left, and re-filed rather than left implicit

The mixed form d.update(other, a=1) from this ticket's gate is still a compile error — a clean refusal, not a wrong value. [[bug-nilpy-dict-update-mixed-positional-and-keyword-args]], prio 35, with the two candidate lowerings and why the choice is not obvious.

Also corrected here: test_nilpy_dict_keyword_args.npy carried a NOTE saying the update half was "built and measured but not shipped (one keyword correct, two segfault)". That has been false since the sibling landed, and a stale note in a test is how a fixed thing gets re-investigated.

Log