Fix stack overflow on self-referential protocol overload filtering#3895
Open
mikeleppane wants to merge 1 commit into
Open
Fix stack overflow on self-referential protocol overload filtering#3895mikeleppane wants to merge 1 commit into
mikeleppane wants to merge 1 commit into
Conversation
Summary: Checking a value against a protocol filters the candidate's overloaded methods by their explicit `self:` annotations (added in 1.1.1 for facebook#3819). When an overload's `self:` is itself a protocol -- e.g. `Lens` with `def __call__[S2, R2](self: Lens[S2, R2], ...)` -- the filter's `is_subset_eq(self_type, self_param)` re-enters protocol conformance, re-looks-up the same overloaded member, and re-filters the same `(self_type, self_param)` pair, recursing without bound. The recursion crosses `is_subset_eq`, which builds a fresh `Subset` on every call, so neither the `(got, want)` subset cache nor the `class_protocol_assumptions` coinductive guard -- both scoped to a single `Subset` -- can observe the cycle. Real code triggers it: pyrefly aborts with a native stack overflow on the kUPS library, a regression since 1.1.1. Add a coinductive guard at the filtering site, stored on `ThreadState` so it survives the `is_subset_eq`/fresh-`Subset` boundary. Before testing a protocol-typed `self:`, record the `(self_type, self_param)` pair; on re-entry of the same pair, assume the overload applies -- the greatest-fixpoint answer for a self-referential protocol, matching mypy and pyright. Non-protocol `self:` annotations cannot recurse and skip the guard entirely, so the common path is unchanged, and the real conformance verdict is still computed by the following attribute-subset check, so non-conforming classes are still rejected. Fixes facebook#3893
Contributor
|
Thanks for investigating this, we did change soem stuff about the solver last version so this seems like a plausible root cause. will review in more detail soon |
Contributor
|
@yangdanny97 has imported this pull request. If you are a Meta employee, you can view this in D109305359. |
|
According to mypy_primer, this change doesn't affect type check results on a corpus of open source code. ✅ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3893
What
pyrefly aborts with a native stack overflow when it checks certain self-referential protocols. The smallest reproduction is a protocol whose overloaded method annotates
selfwith the protocol itself:Before this change pyrefly prints
thread '<unknown>' has overflowed its stack / fatal runtime error: stack overflowand aborts. After it, the file type-checks with no crash. mypy, pyright, and ty all terminate on this input, so the crash was a pyrefly bug rather than bad code.It shows up on real projects.
pyrefly check src/on the kUPS library (https://github.com/cusp-ai-oss/kUPS) crashed partway through; it now runs to completion. The crash is a regression since 1.1.1.Why
When pyrefly checks a value against a protocol, it filters the candidate's overloaded methods by their explicit
self:annotations, so an overload whoseself:cannot match the receiver does not get to bind type variables it should not. That filter landed in 1.1.1 (#3819).The filter tests each overload with
is_subset_eq(self_type, self_param). Whenself_paramis itself a protocol, that check re-enters protocol conformance, looks up the same overloaded member again, and re-filters the same(self_type, self_param)pair. Nothing breaks the loop, so it recurses until the stack is exhausted.pyrefly already has two cycle guards for protocol subtyping, and neither catches this one. Both live on
Subset, andSolver::is_subset_eqbuilds a freshSubseton every call. The recursion passes throughis_subset_eqat each turn, so the(got, want)subset cache and theclass_protocol_assumptionscoinductive set are both empty again by the time the same pair comes back around.How
The guard goes where the recursion is actually observable. It lives on
ThreadState, which persists across theis_subset_eqboundary that resetsSubset.pyrefly/lib/alt/answers_solver.rs: a per-threadoverload_self_filter_stackof in-progress(self_type, self_param)pairs, plusenter_overload_self_filterandexit_overload_self_filter.pyrefly/lib/alt/class/class_field.rs, infilter_overloads_by_self_type: before testing a protocol-typedself:, record the pair; on re-entry of the same pair, assume the overload applies and return. That is the greatest-fixpoint reading of a recursive protocol, which matches mypy and pyright.A non-protocol
self:cannot start this recursion, so it skips the guard and runs the plain check. That keeps the common path free of the extra bookkeeping, including hot cases likestrmethods withself: LiteralStringoverloads. The guard only changes the filter's decision on a real cycle, where keeping the overload is the safe direction. The conformance verdict still comes from the attribute-subset check that runs afterward, so a class that does not satisfy the protocol is still rejected.This leaves the subtyping solver, the existing
Subsetcycle guards, and the unbound class-access caller of the same filter unchanged.Test plan
Three tests in
pyrefly/lib/test/protocol.rs:test_protocol_overloaded_generic_self_referencing_protocol_terminates__call__type-checks instead of crashingtest_protocol_overloaded_generic_self_mutual_recursion_terminatesself:annotations reference each other also terminatetest_protocol_overloaded_generic_self_non_conforming_still_rejected__call__but lacks another required member is still reported as not assignable, so the guard does not hide a real failureEach terminate test aborts the test runner without the fix. The existing
test_protocol_overloaded_method_filtered_by_self(#3819) still passes, so filtering by concrete protocol self-types is unchanged.cargo test -p pyrefly --lib: 5746 passed, 0 failed.mypy_primerover 69 protocol-heavy and overload-heavy projects (pydantic, pandas-stubs, pandera, sympy, xarray, scipy, attrs, strawberry, kornia, jax, and more): no diagnostic changes.