[ty] Prevent stack overflows in recursive type relation checks - #26503
Conversation
d787ff9 to
436b6eb
Compare
ecc9553 to
b379132
Compare
Merging this PR will not alter performance
Comparing Footnotes
|
a83bb7c to
9bd5d89
Compare
|
Does this aim to fix any of the other issues linked from #24683? |
639c347 to
3567df0
Compare
astral-sh/ty#3195 and astral-sh/ty#3196 cannot be fixed with this PR. |
Typing conformance resultsNo changes detected ✅Current numbersThe percentage of diagnostics emitted that were expected errors held steady at 96.83%. The percentage of expected errors that received a diagnostic held steady at 91.74%. The number of fully passing files held steady at 99/133. |
Memory usage reportSummary
Significant changesClick to expand detailed breakdownsphinx
|
|
f93beef to
11fe794
Compare
b4dfb54 to
26e7529
Compare
927fc58 to
fcf067b
Compare
d4cba55 to
d2e6a23
Compare
carljm
left a comment
There was a problem hiding this comment.
Please consider comments (including Micha's comments) and either address or explicitly defer them, but I don't see hard blockers here and I think the core approach makes sense. Thank you!
| // TODO: Recursive aliases can encode context-free languages, whose inclusion and | ||
| // equivalence are undecidable. No complete fallback exists, but more decidable cases | ||
| // can be recognized here before conservatively rejecting the pair. | ||
| return self.never(); |
There was a problem hiding this comment.
Currently this leads to regression on cases of converging recursion that we correctly handle in main today.
main branch (and all other type checkers) handle this, but this PR fails:
type L[T] = tuple[T] | tuple[T, L[int]]
type R[T] = tuple[T] | tuple[T, R[int]]
def f(left: L[str], right: R[str]):
right = left # incorrectly rejected in this PR
left = right # incorrectly rejected in this PRI guess we now consider any recursion with differing specialization as evidence of recursion and give up immediately.
But I guess ecosystem report suggests such patterns may not be common.
There was a problem hiding this comment.
In this case, when we find an item with the same identity, instead of immediately returning Cycle, we can return Pending once to determine whether it is a growing pattern or just a stable recurive alias.
But I realized that there are cases where the recursive alias has to be expanded with different specializations many times before reaching a stable point. For example:
type Left[A, B, C] = tuple[A, Left[B, C, None]]
type Right[A, B, C] = tuple[A, Right[B, C, None]]
# Left[int, int, int] = tuple[int, Left[int, int, None]] = tuple[int, tuple[int, Left[int, None, None]]] = tuple[int, tuple[int, tuple[int, Left[None, None, None]]]]
# Left[None, None, None] (= tuple[None, Left[None, None, None]]) is recursive but stable, so it can be completely determined
static_assert(is_subtype_of(Left[int, int, int], Right[int, int, int]))By increasing the number of type variables, we can defer any number of steps until we reach the stable point. Practically speaking, we should set an expansion limit.
|
|
||
| impl<'db> TypeVisitor<'db> for AliasReferenceVisitor<'db> { | ||
| fn should_visit_lazy_type_attributes(&self) -> bool { | ||
| false |
There was a problem hiding this comment.
This means that recursion via class-backed protocols or typed-dicts is invisible to is_recursive. So this (and the equivalent TypedDict example) still stack overflow on this PR:
from __future__ import annotations
from typing import Protocol
from ty_extensions import static_assert
from ty_extensions._internal import is_subtype_of
class LP[T](Protocol):
child: "LA[list[T]]"
class RP[T](Protocol):
child: "RA[list[T]]"
type LA[T] = LP[T]
type RA[T] = RP[T]
static_assert(not is_subtype_of(LA[int], RA[int]))If this is an intentional scope cut, do you have a plan to fix it as a follow up?
| FunctionLiteral(FunctionLiteral<'db>), | ||
| NewTypeInstance(Definition<'db>), | ||
| RecursiveTypeAlias(Definition<'db>), |
There was a problem hiding this comment.
Is this supposed to enumerate all possibly-recursion-producing types? Protocol and TypedDict seem like notable omissions.
There was a problem hiding this comment.
Yes, Protocol, TypedDict should also be supported here.
It would be easily covered by generalizing this PR mechanism. I will create a follow-up immediately after merging this PR.
Co-authored-by: Micha Reiser <micha@reiser.io>
This reverts commit 334751e.
cb42bc6 to
86ddae3
Compare
|
I'll merge this PR. As for the unresolved issues, I have solutions in mind, but they will require additional review, so I will create follow-ups soon. |
|
Congratulations on the merge! |
Thanks, but there are still 5 draft PRs on top of this PR 😅 |
|
One step at a time :) |
## Summary This PR addresses #26503 (comment) The basic approach is to just add `TypeIdentity::{RecursiveProtocol, RecursiveTypedDict}`, but it was found that calculating `to_identity` for these types was more costly than expected, so I implemented some measures to delay the `to_identity` calculation. ## Test Plan mdtest updated
Summary
Fixes
astral-sh/ty#3452I intended to fix it, but the fixes were separated into #26881, #26882 and #26898. This PR provides a foundation for fixes, but there are observable improvements on this PR alone. For example:
This will result in stack overflow in the current main.
A recursive type like
type StableRecursiveList[T] = T | list[StableRecursiveList[T]]can be checked without any problem even in the current main. This is because the specialization that appears in the recursive type on the right side is the same as the left side, so the recursion guard using simple type equality works.The problem here is that among recursive aliases, the specialization on the right-hand side grows with each expansion. Current recursion guards cannot notice such type alias reentrancy.
Therefore, when considering generic recursive type aliases, two levels of protection must be applied, distinguishing between equivalence based on type definition identity and full type equivalence, including specialization. Applying a recursive guard that only considers the latter will fail to detect cases of growing specialization (this is exactly the problem exposed by MRE). On the other hand, if we guard by considering only the former, specialization will not be considered, so we will treat
GrowingList[int]andGrowingList[str]as the same thing.With this PR, the
CycleDetectorwill now return aCyclestate in addition toReadyandPendingas a result of a visit. If the type aliases, including specializations, are equivalent to types already seen during the visit, the detector will still return the fallback value asReady. If the type aliases are the same but the specializations are different, returnCycleand ask the upstream relation checker to decide. In the case ofTypeRelationChecker, when this is received, it executesrecursive_type_pair_fallbackto complete the determination.So, what should
recursive_type_pair_fallbackdo? If we callcheck_type_pairduring this process, we will enter recursion again, so we need to make it a finite process. As I thought about it, I realized that this problem was undecidable. There is no general subtyping algorithm for growing recursive type aliases. This is because they will have expressive power equal to or greater than that of context-free grammars 1. In other words, determining the subtype of two such recursive aliases is the same problem as determining the equivalence and inclusion of two CFGs that are known to be undecidable.Therefore, we have to give up on this kind of recursive alias typing at some point (FYI, mypy makes growing recursive aliases like this illegal in the first place; pyright allows them, but seems to just have a recursion depth limit).
In this PR, it simply returns a conservative solution immediately when a growing pattern of recursive type alias is detected. In reality, we can extend the support a little more, but we'll leave that as future work and focus on fixes first.
#26881 strengthens the recursion guard in
TypeTransformerunder this PR change. This directly fixes #3452.#26882 strengthens the recursion guard in
UnionBuilderunder this PR change. This will properly stop the expansion of recursive union aliases that would result in stack overflow in the current main.#26898 adds
RecursionGuardthat wrapsTypeCollector.Test Plan
new mdtest cases
Footnotes
To be more specific, the type argument of type alias can be considered as the stack memory of a pushdown automaton. If the generic type alias itself appears on the right side with a different specialization than the left side, it corresponds to being able to push additional information onto the stack along with the state transition. If it has only trivial specializations like the left side, it cannot be used as a stack, and its abilities are equivalent to a finite automaton. ↩