Skip to content

Commit adbf058

Browse files
authored
[ty] Fix rare panic with highly cyclic TypeVar definitions (#21059)
1 parent eb8c0ad commit adbf058

File tree

8 files changed

+318
-71
lines changed

8 files changed

+318
-71
lines changed

Cargo.lock

Lines changed: 3 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ regex-automata = { version = "0.4.9" }
146146
rustc-hash = { version = "2.0.0" }
147147
rustc-stable-hash = { version = "0.1.2" }
148148
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
149-
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "d38145c29574758de7ffbe8a13cd4584c3b09161", default-features = false, features = [
149+
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "25b3ef146cfa2615f4ec82760bd0c22b454d0a12", default-features = false, features = [
150150
"compact_str",
151151
"macros",
152152
"salsa_unstable",
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Iteration count mismatch for highly cyclic type vars
2+
3+
Regression test for <https://github.com/astral-sh/ty/issues/1377>.
4+
5+
The code is an excerpt from <https://github.com/Gobot1234/steam.py> that is minimal enough to
6+
trigger the iteration count mismatch bug in Salsa.
7+
8+
<!-- expect-panic: execute: too many cycle iterations -->
9+
10+
```toml
11+
[environment]
12+
extra-paths= ["/packages"]
13+
```
14+
15+
`main.py`:
16+
17+
```py
18+
from __future__ import annotations
19+
20+
from typing import TypeAlias
21+
22+
from steam.message import Message
23+
24+
TestAlias: TypeAlias = tuple[Message]
25+
```
26+
27+
`/packages/steam/__init__.py`:
28+
29+
```py
30+
31+
```
32+
33+
`/packages/steam/abc.py`:
34+
35+
```py
36+
from __future__ import annotations
37+
38+
from dataclasses import dataclass
39+
from typing import TYPE_CHECKING, Generic, Protocol
40+
41+
from typing_extensions import TypeVar
42+
43+
if TYPE_CHECKING:
44+
from .clan import Clan
45+
from .group import Group
46+
47+
UserT = TypeVar("UserT", covariant=True)
48+
MessageT = TypeVar("MessageT", bound="Message", default="Message", covariant=True)
49+
50+
class Messageable(Protocol[MessageT]): ...
51+
52+
ClanT = TypeVar("ClanT", bound="Clan | None", default="Clan | None", covariant=True)
53+
GroupT = TypeVar("GroupT", bound="Group | None", default="Group | None", covariant=True)
54+
55+
class Channel(Messageable[MessageT], Generic[MessageT, ClanT, GroupT]): ...
56+
57+
ChannelT = TypeVar("ChannelT", bound=Channel, default=Channel, covariant=True)
58+
59+
class Message(Generic[UserT, ChannelT]): ...
60+
```
61+
62+
`/packages/steam/chat.py`:
63+
64+
```py
65+
from __future__ import annotations
66+
67+
from typing import TYPE_CHECKING, Generic, TypeAlias
68+
69+
from typing_extensions import Self, TypeVar
70+
71+
from .abc import Channel, ClanT, GroupT, Message
72+
73+
if TYPE_CHECKING:
74+
from .clan import Clan
75+
from .message import ClanMessage, GroupMessage
76+
77+
ChatT = TypeVar("ChatT", bound="Chat", default="Chat", covariant=True)
78+
MemberT = TypeVar("MemberT", covariant=True)
79+
80+
AuthorT = TypeVar("AuthorT", covariant=True)
81+
82+
class ChatMessage(Message[AuthorT, ChatT], Generic[AuthorT, MemberT, ChatT]): ...
83+
84+
ChatMessageT = TypeVar("ChatMessageT", bound="GroupMessage | ClanMessage", default="GroupMessage | ClanMessage", covariant=True)
85+
86+
class Chat(Channel[ChatMessageT, ClanT, GroupT]): ...
87+
88+
ChatGroupTypeT = TypeVar("ChatGroupTypeT", covariant=True)
89+
90+
class ChatGroup(Generic[MemberT, ChatT, ChatGroupTypeT]): ...
91+
```
92+
93+
`/packages/steam/channel.py`:
94+
95+
```py
96+
from __future__ import annotations
97+
98+
from typing import TYPE_CHECKING, Any
99+
100+
from .chat import Chat
101+
102+
if TYPE_CHECKING:
103+
from .clan import Clan
104+
105+
class ClanChannel(Chat["Clan", None]): ...
106+
```
107+
108+
`/packages/steam/clan.py`:
109+
110+
```py
111+
from __future__ import annotations
112+
113+
from typing import TYPE_CHECKING, TypeVar
114+
115+
from typing_extensions import Self
116+
117+
from .chat import ChatGroup
118+
119+
class Clan(ChatGroup[str], str): ...
120+
```
121+
122+
`/packages/steam/group.py`:
123+
124+
```py
125+
from __future__ import annotations
126+
127+
from .chat import ChatGroup
128+
129+
class Group(ChatGroup[str]): ...
130+
```
131+
132+
`/packages/steam/message.py`:
133+
134+
```py
135+
from __future__ import annotations
136+
137+
from typing import TYPE_CHECKING
138+
139+
from typing_extensions import TypeVar
140+
141+
from .abc import BaseUser, Message
142+
from .chat import ChatMessage
143+
144+
if TYPE_CHECKING:
145+
from .channel import ClanChannel
146+
147+
class GroupMessage(ChatMessage["str"]): ...
148+
class ClanMessage(ChatMessage["ClanChannel"]): ...
149+
```

crates/ty_test/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,11 @@ ty_static = { workspace = true }
2323
ty_vendored = { workspace = true }
2424

2525
anyhow = { workspace = true }
26-
bitflags = { workspace = true }
2726
camino = { workspace = true }
2827
colored = { workspace = true }
2928
insta = { workspace = true, features = ["filters"] }
3029
memchr = { workspace = true }
31-
path-slash ={ workspace = true }
30+
path-slash = { workspace = true }
3231
regex = { workspace = true }
3332
rustc-hash = { workspace = true }
3433
rustc-stable-hash = { workspace = true }

crates/ty_test/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,20 @@ snapshotting.
180180
At present, there is no way to do inline snapshotting or to request more granular
181181
snapshotting of specific diagnostics.
182182

183+
## Expected panics
184+
185+
It is possible to write tests that expect the type checker to panic during checking. Ideally, we'd fix those panics
186+
but being able to add regression tests even before is useful.
187+
188+
To mark a test as expecting a panic, add an HTML comment like this:
189+
190+
```markdown
191+
<!-- expect-panic: assertion `left == right` failed: Can't merge cycle heads -->
192+
```
193+
194+
The text after `expect-panic:` is a substring that must appear in the panic message. The message is optional;
195+
but it is recommended to avoid false positives.
196+
183197
## Multi-file tests
184198

185199
Some tests require multiple files, with imports from one file into another. For this purpose,

crates/ty_test/src/lib.rs

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use parser as test_parser;
88
use ruff_db::Db as _;
99
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, DisplayDiagnosticConfig};
1010
use ruff_db::files::{File, FileRootKind, system_path_to_file};
11-
use ruff_db::panic::catch_unwind;
11+
use ruff_db::panic::{PanicError, catch_unwind};
1212
use ruff_db::parsed::parsed_module;
1313
use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf};
1414
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
@@ -319,6 +319,7 @@ fn run_test(
319319
let mut snapshot_diagnostics = vec![];
320320

321321
let mut any_pull_types_failures = false;
322+
let mut panic_info = None;
322323

323324
let mut failures: Failures = test_files
324325
.iter()
@@ -338,10 +339,17 @@ fn run_test(
338339
.map(|error| Diagnostic::invalid_syntax(test_file.file, error, error)),
339340
);
340341

341-
let mdtest_result = attempt_test(db, check_types, test_file, "run mdtest", None);
342+
let mdtest_result = attempt_test(db, check_types, test_file);
342343
let type_diagnostics = match mdtest_result {
343344
Ok(diagnostics) => diagnostics,
344-
Err(failures) => return Some(failures),
345+
Err(failures) => {
346+
if test.should_expect_panic().is_ok() {
347+
panic_info = Some(failures.info);
348+
return None;
349+
}
350+
351+
return Some(failures.into_file_failures(db, "run mdtest", None));
352+
}
345353
};
346354

347355
diagnostics.extend(type_diagnostics);
@@ -367,22 +375,20 @@ fn run_test(
367375
}));
368376
}
369377

370-
let pull_types_result = attempt_test(
371-
db,
372-
pull_types,
373-
test_file,
374-
"\"pull types\"",
375-
Some(
376-
"Note: either fix the panic or add the `<!-- pull-types:skip -->` \
377-
directive to this test",
378-
),
379-
);
378+
let pull_types_result = attempt_test(db, pull_types, test_file);
380379
match pull_types_result {
381380
Ok(()) => {}
382381
Err(failures) => {
383382
any_pull_types_failures = true;
384383
if !test.should_skip_pulling_types() {
385-
return Some(failures);
384+
return Some(failures.into_file_failures(
385+
db,
386+
"\"pull types\"",
387+
Some(
388+
"Note: either fix the panic or add the `<!-- pull-types:skip -->` \
389+
directive to this test",
390+
),
391+
));
386392
}
387393
}
388394
}
@@ -391,6 +397,39 @@ fn run_test(
391397
})
392398
.collect();
393399

400+
match panic_info {
401+
Some(panic_info) => {
402+
let expected_message = test
403+
.should_expect_panic()
404+
.expect("panic_info is only set when `should_expect_panic` is `Ok`");
405+
406+
let message = panic_info
407+
.payload
408+
.as_str()
409+
.unwrap_or("Box<dyn Any>")
410+
.to_string();
411+
412+
if let Some(expected_message) = expected_message {
413+
assert!(
414+
message.contains(expected_message),
415+
"Test `{}` is expected to panic with `{expected_message}`, but panicked with `{message}` instead.",
416+
test.name()
417+
);
418+
}
419+
}
420+
None => {
421+
if let Ok(message) = test.should_expect_panic() {
422+
if let Some(message) = message {
423+
panic!(
424+
"Test `{}` is expected to panic with `{message}`, but it didn't.",
425+
test.name()
426+
);
427+
}
428+
panic!("Test `{}` is expected to panic but it didn't.", test.name());
429+
}
430+
}
431+
}
432+
394433
if test.should_skip_pulling_types() && !any_pull_types_failures {
395434
let mut by_line = matcher::FailuresByLine::default();
396435
by_line.push(
@@ -596,17 +635,32 @@ fn create_diagnostic_snapshot(
596635
///
597636
/// If a panic occurs, a nicely formatted [`FileFailures`] is returned as an `Err()` variant.
598637
/// This will be formatted into a diagnostic message by `ty_test`.
599-
fn attempt_test<'db, T, F>(
638+
fn attempt_test<'db, 'a, T, F>(
600639
db: &'db Db,
601640
test_fn: F,
602-
test_file: &TestFile,
603-
action: &str,
604-
clarification: Option<&str>,
605-
) -> Result<T, FileFailures>
641+
test_file: &'a TestFile,
642+
) -> Result<T, AttemptTestError<'a>>
606643
where
607644
F: FnOnce(&'db dyn ty_python_semantic::Db, File) -> T + std::panic::UnwindSafe,
608645
{
609-
catch_unwind(|| test_fn(db, test_file.file)).map_err(|info| {
646+
catch_unwind(|| test_fn(db, test_file.file))
647+
.map_err(|info| AttemptTestError { info, test_file })
648+
}
649+
650+
struct AttemptTestError<'a> {
651+
info: PanicError,
652+
test_file: &'a TestFile,
653+
}
654+
655+
impl AttemptTestError<'_> {
656+
fn into_file_failures(
657+
self,
658+
db: &Db,
659+
action: &str,
660+
clarification: Option<&str>,
661+
) -> FileFailures {
662+
let info = self.info;
663+
610664
let mut by_line = matcher::FailuresByLine::default();
611665
let mut messages = vec![];
612666
match info.location {
@@ -652,8 +706,8 @@ where
652706
by_line.push(OneIndexed::from_zero_indexed(0), messages);
653707

654708
FileFailures {
655-
backtick_offsets: test_file.backtick_offsets.clone(),
709+
backtick_offsets: self.test_file.backtick_offsets.clone(),
656710
by_line,
657711
}
658-
})
712+
}
659713
}

0 commit comments

Comments
 (0)