Skip to content

gh-156664: Fix interaction between free variables and comprehensions - #156691

Open
JelleZijlstra wants to merge 3 commits into
python:mainfrom
JelleZijlstra:codex/gh-156664-comprehension-closure
Open

gh-156664: Fix interaction between free variables and comprehensions#156691
JelleZijlstra wants to merge 3 commits into
python:mainfrom
JelleZijlstra:codex/gh-156664-comprehension-closure

Conversation

@JelleZijlstra

@JelleZijlstra JelleZijlstra commented Aug 31, 2026

Copy link
Copy Markdown
Member

@carljm carljm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Codex found a couple regressions that this causes relative to main.

Comment thread Python/symtable.c
// This binding was copied from an inlined comprehension, not
// defined in this scope. Another child may still need the
// name from an enclosing scope.
if (PyDict_SetItem(scopes, name, v_free) < 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conversion can leave a null cell slot when the current block is itself an inlined comprehension. The copied symbol still has DEF_LOCAL, so codegen_push_inlined_comprehension_locals() clears the enclosing cell with LOAD_FAST_AND_CLEAR, but now skips MAKE_CELL because the scope is FREE. After the nested comprehension restores the null slot, a subsequent closure capture or dereference crashes.

def f(x):
    return [([lambda: x for x in [1]], lambda: x) for y in [0]]

f(7)

This completes successfully on main, but aborts on the LOAD_FAST_BORROW non-null assertion with this PR. Replacing the second lambda with [x for _ in [0]] produces a segmentation fault in LOAD_DEREF.

Comment thread Python/compile.c
|| in_class_block) {
// enclosing scope. A name that is a cell in the comprehension and free
// outside it uses separate cell and free-variable slots.
if ((scope != outsc && scope != FREE) || in_class_block) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With separate cell and free-variable slots, both slots contain values during the comprehension, and frame.f_locals exposes the shared name twice. Its keys(), items(), values(), and length iterate the slots without deduplicating names. This breaks keyword expansion, for example:

import sys

def outer():
    x = [1]
    def inner():
        return [(lambda: x, dict(**sys._getframe().f_locals))
                for x in x]
    return inner()

outer()

Main succeeds, with {'x': 1} in the result, while this PR raises TypeError: dict() got multiple values for keyword argument 'x'. Likewise, dict(sys._getframe().f_locals.items())["x"] returns the enclosing [1] instead of the comprehension's 1, even though direct lookup returns 1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting piece of code, I guess we'll have to filter out cells when creating the locals proxy.

My Codex came up with this variant:

import sys

def write_x(value):
    proxy = sys._getframe(1).f_locals
    proxy["x"] = value
    return proxy["x"]

def outer():
    x = 3

    def f():
        proxy_saw = write_x(4)
        funcs = [lambda: x for x in [1]]
        return proxy_saw, x, funcs[0]()

    return f()

print(outer())

Which works differently on this PR and main. It seemed a bit out there so I didn't post it, but your repro is more likely to break real code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants