Skip to content

[ty] Avoid stack overflow with recursive typevar#23652

Merged
charliermarsh merged 6 commits intomainfrom
charlie/self-recurse
Mar 4, 2026
Merged

[ty] Avoid stack overflow with recursive typevar#23652
charliermarsh merged 6 commits intomainfrom
charlie/self-recurse

Conversation

@charliermarsh
Copy link
Member

Summary

Use a syntactic check rather than eagerly resolving the type.

Closes astral-sh/ty#2889

@charliermarsh charliermarsh added bug Something isn't working ty Multi-file analysis & type inference labels Mar 2, 2026
@astral-sh-bot
Copy link

astral-sh-bot bot commented Mar 2, 2026

Typing conformance results

No changes detected ✅

@charliermarsh charliermarsh force-pushed the charlie/self-recurse branch from d971798 to 1c93d1b Compare March 2, 2026 01:30
@astral-sh-bot
Copy link

astral-sh-bot bot commented Mar 2, 2026

mypy_primer results

Changes were detected when running on open source projects
spack (https://github.com/spack/spack)
- lib/spack/spack/test/conftest.py:1918:16: error[unresolved-attribute] Attribute `get` is not defined on `None` in union `Element[str] | None`
+ lib/spack/spack/test/conftest.py:1918:16: error[unresolved-attribute] Attribute `get` is not defined on `None` in union `Element[Unknown] | None`

meson (https://github.com/mesonbuild/meson)
- mesonbuild/mtest.py:1119:17: error[invalid-assignment] Object of type `ElementTree[Element[str]]` is not assignable to attribute `junit` of type `ElementTree[Element[str] | None] | None`
+ mesonbuild/mtest.py:1119:17: error[invalid-assignment] Object of type `ElementTree[Element[Unknown]]` is not assignable to attribute `junit` of type `ElementTree[Element[Unknown] | None] | None`

cloud-init (https://github.com/canonical/cloud-init)
- cloudinit/sources/helpers/azure.py:518:32: error[unresolved-attribute] Attribute `text` is not defined on `None` in union `Element[str] | None`
+ cloudinit/sources/helpers/azure.py:518:32: error[unresolved-attribute] Attribute `text` is not defined on `None` in union `Element[Unknown] | None`

scikit-build-core (https://github.com/scikit-build/scikit-build-core)
- src/scikit_build_core/build/wheel.py:99:20: error[no-matching-overload] No overload of bound method `__init__` matches arguments
- Found 58 diagnostics
+ Found 57 diagnostics

dd-trace-py (https://github.com/DataDog/dd-trace-py)
- tests/contrib/pytest/test_pytest_atr.py:337:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_atr.py:337:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_atr.py:338:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_atr.py:338:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_atr.py:339:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_atr.py:339:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_atr.py:340:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_atr.py:340:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:259:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:259:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:260:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:260:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:261:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:261:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:262:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:262:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:271:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:271:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:272:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:272:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:273:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:273:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_attempt_to_fix.py:274:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_attempt_to_fix.py:274:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_efd.py:515:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_efd.py:515:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`
- tests/contrib/pytest/test_pytest_efd.py:516:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[str] | None`
+ tests/contrib/pytest/test_pytest_efd.py:516:16: error[unresolved-attribute] Attribute `attrib` is not defined on `None` in union `Element[Unknown] | None`

zulip (https://github.com/zulip/zulip)
- zerver/lib/markdown/nested_code_blocks.py:51:21: error[unresolved-attribute] Attribute `tag` is not defined on `None` in union `Element[str] | None`
+ zerver/lib/markdown/nested_code_blocks.py:51:21: error[unresolved-attribute] Attribute `tag` is not defined on `None` in union `Element[Unknown] | None`

@astral-sh-bot
Copy link

astral-sh-bot bot commented Mar 2, 2026

Memory usage report

Summary

Project Old New Diff Outcome
prefect 692.25MB 693.04MB +0.11% (814.21kB)
sphinx 266.74MB 266.92MB +0.07% (188.02kB)
trio 118.46MB 118.51MB +0.04% (54.07kB)
flake8 48.07MB 48.10MB +0.05% (22.82kB)

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_definition_types 87.39MB 87.62MB +0.27% (241.16kB)
infer_expression_types_impl 59.67MB 59.86MB +0.31% (191.74kB)
infer_deferred_types 14.17MB 14.24MB +0.50% (72.94kB)
infer_scope_types_impl 51.65MB 51.71MB +0.12% (65.93kB)
is_redundant_with_impl 5.60MB 5.66MB +0.96% (55.08kB)
infer_expression_type_impl 14.17MB 14.21MB +0.28% (40.73kB)
bound_typevar_default_type 0.00B 28.52kB +28.52kB (new)
StaticClassLiteral<'db>::implicit_attribute_inner_ 9.66MB 9.68MB +0.24% (23.44kB)
all_narrowing_constraints_for_expression 6.85MB 6.87MB +0.24% (17.07kB)
FunctionType<'db>::signature_ 3.84MB 3.86MB +0.35% (13.76kB)
FunctionType<'db>::last_definition_signature_ 753.94kB 765.71kB +1.56% (11.78kB)
all_negative_narrowing_constraints_for_expression 2.57MB 2.58MB +0.35% (9.12kB)
Type<'db>::member_lookup_with_policy_ 15.19MB 15.20MB +0.06% (9.01kB)
FunctionType<'db>::last_definition_raw_signature_ 3.16MB 3.17MB +0.23% (7.58kB)
Type<'db>::try_call_dunder_get_ 10.26MB 10.26MB +0.05% (5.39kB)
... 20 more

sphinx

Name Old New Diff Outcome
infer_expression_types_impl 21.79MB 21.84MB +0.22% (50.05kB)
infer_definition_types 24.26MB 24.30MB +0.17% (42.36kB)
infer_deferred_types 5.62MB 5.65MB +0.60% (34.29kB)
bound_typevar_default_type 0.00B 16.22kB +16.22kB (new)
infer_scope_types_impl 15.62MB 15.63MB +0.08% (13.14kB)
is_redundant_with_impl 1.84MB 1.86MB +0.68% (12.76kB)
Type<'db>::try_call_dunder_get_ 4.96MB 4.97MB +0.16% (7.90kB)
FunctionType<'db>::signature_ 2.30MB 2.30MB +0.12% (2.73kB)
FunctionType<'db>::last_definition_signature_ 226.48kB 229.07kB +1.14% (2.59kB)
Type<'db>::class_member_with_policy_ 7.60MB 7.59MB -0.02% (1.77kB)
StaticClassLiteral<'db>::try_mro_ 2.18MB 2.18MB +0.08% (1.75kB)
StaticClassLiteral<'db>::is_typed_dict_ 182.61kB 184.05kB +0.79% (1.44kB)
all_narrowing_constraints_for_expression 2.36MB 2.36MB +0.06% (1.39kB)
code_generator_of_static_class 534.80kB 535.90kB +0.21% (1.10kB)
enum_metadata 737.94kB 738.93kB +0.13% (1020.00B)
... 26 more

trio

Name Old New Diff Outcome
bound_typevar_default_type 0.00B 14.75kB +14.75kB (new)
infer_deferred_types 2.36MB 2.37MB +0.41% (9.94kB)
infer_expression_types_impl 7.15MB 7.15MB +0.07% (5.07kB)
infer_scope_types_impl 4.79MB 4.80MB +0.10% (4.85kB)
infer_definition_types 7.62MB 7.63MB +0.05% (3.83kB)
FunctionType<'db>::signature_ 1.07MB 1.08MB +0.31% (3.39kB)
is_redundant_with_impl 496.30kB 499.46kB +0.64% (3.16kB)
FunctionType<'db>::last_definition_signature_ 242.25kB 245.38kB +1.29% (3.13kB)
Type<'db>::try_call_dunder_get_ 1.39MB 1.39MB +0.12% (1.70kB)
StaticClassLiteral<'db>::try_mro_ 895.07kB 896.73kB +0.19% (1.66kB)
StaticClassLiteral<'db>::is_typed_dict_ 65.84kB 67.30kB +2.21% (1.45kB)
code_generator_of_static_class 212.95kB 214.24kB +0.61% (1.29kB)
enum_metadata 238.44kB 239.44kB +0.42% (1020.00B)
FunctionType<'db>::last_definition_raw_signature_ 600.76kB 601.71kB +0.16% (972.00B)
infer_expression_type_impl 1.49MB 1.49MB -0.05% (804.00B)
... 12 more

flake8

Name Old New Diff Outcome
bound_typevar_default_type 0.00B 6.79kB +6.79kB (new)
infer_deferred_types 690.71kB 695.06kB +0.63% (4.35kB)
infer_definition_types 1.88MB 1.88MB +0.18% (3.48kB)
infer_expression_types_impl 1.07MB 1.07MB +0.18% (2.03kB)
infer_scope_types_impl 1006.95kB 1008.01kB +0.11% (1.07kB)
is_redundant_with_impl 150.35kB 151.28kB +0.62% (948.00B)
FunctionType<'db>::signature_ 361.54kB 362.38kB +0.23% (852.00B)
FunctionType<'db>::last_definition_signature_ 59.29kB 60.05kB +1.28% (780.00B)
StaticClassLiteral<'db>::try_mro_ 346.35kB 347.10kB +0.22% (768.00B)
StaticClassLiteral<'db>::is_typed_dict_ 27.98kB 28.64kB +2.39% (684.00B)
Type<'db>::try_call_dunder_get_ 377.23kB 377.83kB +0.16% (612.00B)
code_generator_of_static_class 60.97kB 61.30kB +0.54% (336.00B)
Type<'db>::class_member_with_policy_ 554.27kB 554.08kB -0.03% (192.00B)
enum_metadata 65.85kB 66.04kB +0.28% (192.00B)
all_negative_narrowing_constraints_for_expression 40.73kB 40.79kB +0.14% (60.00B)
... 7 more

@charliermarsh charliermarsh marked this pull request as ready for review March 2, 2026 01:38
Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

This fixes the issue, but it feels like it's patching a symptom rather than addressing the underlying cause. TypeVarInstance::default_type is already a wrapper around the low-level field _default_type that's stored directly on the Salsa struct. It feels like we should be adding cycle handling to TypeVarInstance::default_type, since apparently any access of t.default_type(db) is enough to trigger infinite recursion currently, given a sufficiently pathological typevar?

You can take a look at the BinaryComparisonVisitor, and the way we thread that through infer_binary_type_comparison, for an example of how we might add cycle detection to lazy_default

@charliermarsh charliermarsh marked this pull request as draft March 2, 2026 14:47
@charliermarsh
Copy link
Member Author

It's proving to be harder to do that here because default_type is called from many disparate sites...

@AlexWaygood
Copy link
Member

can you add an inner function to default_type that passes the cycle visitor through, and keep the signature of default_type the same...?

@AlexWaygood AlexWaygood assigned AlexWaygood and unassigned dcreager Mar 2, 2026
@charliermarsh charliermarsh force-pushed the charlie/self-recurse branch from e04f35d to 96e2835 Compare March 3, 2026 00:05
@charliermarsh charliermarsh marked this pull request as ready for review March 3, 2026 01:00
Comment on lines +7241 to +7246
{
let mut seen_typevars = seen_typevars.borrow_mut();
if !seen_typevars.insert(typevar) {
return false;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

can be a bit simpler

Suggested change
{
let mut seen_typevars = seen_typevars.borrow_mut();
if !seen_typevars.insert(typevar) {
return false;
}
}
if !seen_typevars.borrow_mut().insert(typevar) {
return false;
}

(I'm still working through the rest of the PR, just posting this now so I don't forget about it...)

Comment on lines +7270 to +7275
{
let mut seen_type_aliases = seen_type_aliases.borrow_mut();
if !seen_type_aliases.insert(type_alias) {
return false;
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
{
let mut seen_type_aliases = seen_type_aliases.borrow_mut();
if !seen_type_aliases.insert(type_alias) {
return false;
}
}
if !seen_type_aliases.borrow_mut().insert(type_alias) {
return false;
}

@AlexWaygood
Copy link
Member

I think you can improve readability for the type_is_self_referential method by creating a State struct that holds the common variables we're passing around everywhere, e.g.

    fn type_is_self_referential(
        self,
        db: &'db dyn Db,
        ty: Type<'db>,
        visitor: &TypeVarDefaultVisitor<'db>,
    ) -> bool {
        #[derive(Copy, Clone)]
        struct State<'db, 'a> {
            db: &'db dyn Db,
            visitor: &'a TypeVarDefaultVisitor<'db>,
            seen_typevars: &'a RefCell<FxHashSet<TypeVarInstance<'db>>>,
            seen_type_aliases: &'a RefCell<FxHashSet<TypeAliasType<'db>>>,
        }

        fn typevar_default_is_self_referential<'db>(
            state: State<'db, '_>,
            typevar: TypeVarInstance<'db>,
            self_identity: TypeVarIdentity<'db>,
        ) -> bool {
            if typevar.identity(state.db) == self_identity {
                return true;
            }

            if !state.seen_typevars.borrow_mut().insert(typevar) {
                return false;
            }

            typevar
                .default_type_impl(state.db, state.visitor)
                .is_some_and(|default_ty| {
                    type_is_self_referential_impl(state, default_ty, self_identity)
                })
        }

        fn type_alias_is_self_referential<'db>(
            state: State<'db, '_>,
            type_alias: TypeAliasType<'db>,
            self_identity: TypeVarIdentity<'db>,
        ) -> bool {
            if !state.seen_type_aliases.borrow_mut().insert(type_alias) {
                return false;
            }

            type_is_self_referential_impl(state, type_alias.raw_value_type(state.db), self_identity)
        }

        fn type_is_self_referential_impl<'db>(
            state: State<'db, '_>,
            ty: Type<'db>,
            self_identity: TypeVarIdentity<'db>,
        ) -> bool {
            any_over_type(state.db, ty, false, |inner_ty| match inner_ty {
                Type::TypeVar(bound_typevar) => typevar_default_is_self_referential(
                    state,
                    bound_typevar.typevar(state.db),
                    self_identity,
                ),
                Type::KnownInstance(KnownInstanceType::TypeVar(typevar)) => {
                    typevar_default_is_self_referential(state, typevar, self_identity)
                }
                Type::TypeAlias(alias) => {
                    type_alias_is_self_referential(state, alias, self_identity)
                }
                Type::KnownInstance(KnownInstanceType::TypeAliasType(alias)) => {
                    type_alias_is_self_referential(state, alias, self_identity)
                }
                _ => false,
            })
        }

        let seen_typevars = RefCell::new(FxHashSet::default());
        let seen_type_aliases = RefCell::new(FxHashSet::default());

        let state = State {
            db,
            visitor,
            seen_typevars: &seen_typevars,
            seen_type_aliases: &seen_type_aliases,
        };

        type_is_self_referential_impl(state, ty, self.identity(db))
    }

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Modulo my suggestions regarding readability, this LGTM, thanks!

Comment on lines -540 to -543
if typevar.default_type(db).is_some() {
typevar_with_defaults += 1;
}

Copy link
Member

Choose a reason for hiding this comment

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

What's the motivation for this change?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think this was a mistake, but re-adding it surfaced another panic, and I had to add cycle handling for bound_typevar_default_type...

Copy link
Member Author

Choose a reason for hiding this comment

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

(I think this specific one at least could be avoided if I just did a syntactic query for the default type presence.)

@charliermarsh charliermarsh enabled auto-merge (squash) March 3, 2026 14:18
@charliermarsh charliermarsh force-pushed the charlie/self-recurse branch from 9469e1f to b4b57eb Compare March 3, 2026 14:56
@charliermarsh charliermarsh disabled auto-merge March 3, 2026 15:43
@carljm carljm removed their request for review March 3, 2026 19:45
@charliermarsh charliermarsh merged commit 1d123a8 into main Mar 4, 2026
51 checks passed
@charliermarsh charliermarsh deleted the charlie/self-recurse branch March 4, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stack overflow on divergently recursive type statement with typevar with itself as the default

3 participants