Skip to content

Commit f06cf49

Browse files
authored
Fix hang in constraint solver for cyclic TypeVar lower bounds (#11413) (#11415)
* Fix hang in constraint solver for cyclic TypeVar lower bounds (#11413) When the constraint solver established a TypeVar's first lower bound, it would unconditionally record the source type even when that source type contained the destination TypeVar nested inside another generic constructor (e.g. R := R | Awaitable[R], as produced when matching wrapt 2.1.x's wrap_function_wrapper against a generic make_wrapper). Each subsequent round of solveAndApplyConstraints substituted R with its own ever-growing bound, producing exponentially larger types and hanging the analyzer. Add an occurs check in assignUnconstrainedTypeVar: if adjSrcType references destType at a strictly nested position, refuse the assignment rather than recording a non-finitely-solvable constraint. Top-level union members that are exactly the TypeVar (e.g. T := T | int from protocol matching against T | int) are explicitly allowed and resolved by the existing logic. Adds a regression test (paramSpec56) that hangs without the fix and completes in ~640ms with it. * Apply prettier formatting
1 parent 3619ecd commit f06cf49

3 files changed

Lines changed: 122 additions & 0 deletions

File tree

packages/pyright-internal/src/analyzer/constraintSolver.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,25 @@ function assignUnconstrainedTypeVar(
763763
} else {
764764
if (!curLowerBound || isTypeSame(destType, curLowerBound)) {
765765
// There was previously no lower bound. We've now established one.
766+
// Apply an occurs check: if `adjSrcType` references `destType`
767+
// (the TypeVar being solved) at a strictly nested position
768+
// (e.g. `R := F[R]` or `R := R | Awaitable[R]`), recording it as
769+
// the lower bound creates a cyclic constraint that subsequent
770+
// widening / substitution rounds expand into an exponentially
771+
// growing recursive type. Such a constraint has no finite
772+
// solution, so report the assignment as a failure rather than
773+
// letting the analyzer hang. See microsoft/pyright#11413.
774+
//
775+
// Top-level union members that are exactly `destType` (e.g.
776+
// `T := T | int` arising from protocol matching against `T | int`)
777+
// are *not* considered cyclic - the original `adjSrcType` is
778+
// recorded as the lower bound and existing logic resolves it.
779+
if (typeVarOccursIn(destType, adjSrcType)) {
780+
diag?.addMessage(
781+
LocAddendum.typeAssignmentMismatch().format(evaluator.printSrcDestTypes(adjSrcType, destType))
782+
);
783+
return false;
784+
}
766785
newLowerBound = adjSrcType;
767786
} else if (isTypeSame(curLowerBound, adjSrcType, {}, recursionCount)) {
768787
// If this is an invariant context and there is currently no upper bound
@@ -1289,6 +1308,39 @@ function assignParamSpec(
12891308
return isAssignable;
12901309
}
12911310

1311+
// Returns true if `typeVar` appears strictly inside `type` (i.e. nested
1312+
// within another type, not as the top-level type itself or as a top-level
1313+
// subtype of a union). Used as an occurs check to detect cyclic constraints
1314+
// during widening. A bare top-level reference is fine (`T := T` is the
1315+
// identity); a top-level union member can be subtracted before solving;
1316+
// only a strictly nested reference (`T := F[T]`) has no finite solution.
1317+
function typeVarOccursIn(typeVar: TypeVarType, type: Type): boolean {
1318+
// A bare top-level TypeVar reference is not a cycle. Compare by name +
1319+
// scope id rather than identity since pyright sometimes clones TypeVars.
1320+
if (isTypeVar(type) && type.shared.name === typeVar.shared.name && type.priv.scopeId === typeVar.priv.scopeId) {
1321+
return false;
1322+
}
1323+
1324+
// Top-level union members that are exactly `typeVar` are also fine; only
1325+
// count occurrences strictly inside other types.
1326+
if (isUnion(type)) {
1327+
for (const subtype of type.priv.subtypes) {
1328+
if (typeVarOccursIn(typeVar, subtype)) {
1329+
return true;
1330+
}
1331+
}
1332+
return false;
1333+
}
1334+
1335+
const tvars = getTypeVarArgsRecursive(type);
1336+
for (const tv of tvars) {
1337+
if (tv.shared.name === typeVar.shared.name && tv.priv.scopeId === typeVar.priv.scopeId) {
1338+
return true;
1339+
}
1340+
}
1341+
return false;
1342+
}
1343+
12921344
// For normal TypeVars, the constraint solver can widen a type by combining
12931345
// two otherwise incompatible types into a union. For TypeVarTuples, we need
12941346
// to do the equivalent operation for unpacked tuples.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# This sample tests a case derived from the wrapt 2.1.x stubs that
2+
# previously caused pyright to hang. A function parameter is typed
3+
# as a union of three generic Callable aliases, each parameterized
4+
# with the same ParamSpec and TypeVar, and the argument is itself a
5+
# generic Callable whose return type is a union (`R | Awaitable[R]`).
6+
# Bidirectional inference must terminate (and not blow up
7+
# combinatorially) on this case.
8+
9+
from collections.abc import Awaitable, Callable
10+
from typing import Any, ParamSpec, TypeVar
11+
12+
P = ParamSpec("P")
13+
R = TypeVar("R", covariant=True)
14+
15+
GenericCallableWrapperFunction = Callable[
16+
[Callable[P, R], Any, tuple[Any, ...], dict[str, Any]], R
17+
]
18+
ClassMethodWrapperFunction = Callable[
19+
[type[Any], Callable[P, R], Any, tuple[Any, ...], dict[str, Any]], R
20+
]
21+
InstanceMethodWrapperFunction = Callable[
22+
[Any, Callable[P, R], Any, tuple[Any, ...], dict[str, Any]], R
23+
]
24+
WrapperFunction = (
25+
GenericCallableWrapperFunction[P, R]
26+
| ClassMethodWrapperFunction[P, R]
27+
| InstanceMethodWrapperFunction[P, R]
28+
)
29+
30+
31+
def wrap_function_wrapper(
32+
target: str, name: str, wrapper: WrapperFunction[P, R]
33+
) -> None: ...
34+
35+
36+
def make_async_wrapper[**P1, R1]() -> Callable[
37+
[Callable[P1, R1], Any, tuple[Any, ...], dict[str, Any]], Awaitable[R1]
38+
]: ...
39+
40+
41+
def make_sync_wrapper[**P1, R1]() -> Callable[
42+
[Callable[P1, R1], Any, tuple[Any, ...], dict[str, Any]], R1
43+
]: ...
44+
45+
46+
def make_wrapper[**P1, R1](
47+
is_async: bool,
48+
) -> Callable[
49+
[Callable[P1, R1], Any, tuple[Any, ...], dict[str, Any]], R1 | Awaitable[R1]
50+
]:
51+
return make_async_wrapper() if is_async else make_sync_wrapper()
52+
53+
54+
# This call must terminate. The constraint solver sees a cyclic
55+
# constraint (R := R | Awaitable[R]) which has no finite solution,
56+
# so it refuses to bind R and the call returns no useful inferred type.
57+
# Analysis must not hang.
58+
wrap_function_wrapper("example", "target", make_wrapper(False))

packages/pyright-internal/src/tests/typeEvaluator4.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,18 @@ test('ParamSpec55', () => {
883883
TestUtils.validateResults(results, 1);
884884
});
885885

886+
// Regression test for a hang reported in microsoft/pyright#11413,
887+
// reproduced from the wrapt 2.1.x stubs. Analysis must terminate;
888+
// the call below sets up a cyclic constraint (R := R | Awaitable[R])
889+
// which has no finite solution. The constraint solver detects the cycle
890+
// and refuses to bind R, but the failure isn't surfaced as a user-
891+
// visible error here (it occurs during bidirectional inference for an
892+
// argument expression). The important thing is that analysis completes.
893+
test('ParamSpec56', () => {
894+
const results = TestUtils.typeAnalyzeSampleFiles(['paramSpec56.py']);
895+
TestUtils.validateResults(results, 0);
896+
});
897+
886898
test('Slice1', () => {
887899
const results = TestUtils.typeAnalyzeSampleFiles(['slice1.py']);
888900
TestUtils.validateResults(results, 0);

0 commit comments

Comments
 (0)