Skip to content

Commit fd9882a

Browse files
authored
[red-knot] avoid unnecessary evaluation of visibility constraint on definitely-unbound symbol (#17326)
This causes spurious query cycles. This PR also includes an update to Salsa, which gives us db events on cycle iteration, so we can write tests asserting the absence of a cycle.
1 parent 66a33bf commit fd9882a

File tree

5 files changed

+65
-12
lines changed

5 files changed

+65
-12
lines changed

Cargo.lock

Lines changed: 3 additions & 3 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
@@ -124,7 +124,7 @@ rayon = { version = "1.10.0" }
124124
regex = { version = "1.10.2" }
125125
rustc-hash = { version = "2.0.0" }
126126
# When updating salsa, make sure to also update the revision in `fuzz/Cargo.toml`
127-
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "b165ba7bd1b2a0112ce574a082ab8ea5102252ac" }
127+
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "87bf6b6c2d5f6479741271da73bd9d30c2580c26" }
128128
schemars = { version = "0.8.16" }
129129
seahash = { version = "4.1.0" }
130130
serde = { version = "1.0.197", features = ["derive"] }

crates/red_knot_python_semantic/src/symbol.rs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -663,15 +663,24 @@ fn symbol_from_bindings_impl<'db>(
663663
requires_explicit_reexport.is_yes() && !binding.is_reexported(db)
664664
};
665665

666-
let unbound_visibility = match bindings_with_constraints.peek() {
666+
let unbound_visibility_constraint = match bindings_with_constraints.peek() {
667667
Some(BindingWithConstraints {
668668
binding,
669669
visibility_constraint,
670670
narrowing_constraint: _,
671-
}) if binding.is_none_or(is_non_exported) => {
672-
visibility_constraints.evaluate(db, predicates, *visibility_constraint)
673-
}
674-
_ => Truthiness::AlwaysFalse,
671+
}) if binding.is_none_or(is_non_exported) => Some(*visibility_constraint),
672+
_ => None,
673+
};
674+
675+
// Evaluate this lazily because we don't always need it (for example, if there are no visible
676+
// bindings at all, we don't need it), and it can cause us to evaluate visibility constraint
677+
// expressions, which is extra work and can lead to cycles.
678+
let unbound_visibility = || {
679+
unbound_visibility_constraint
680+
.map(|visibility_constraint| {
681+
visibility_constraints.evaluate(db, predicates, visibility_constraint)
682+
})
683+
.unwrap_or(Truthiness::AlwaysFalse)
675684
};
676685

677686
let mut types = bindings_with_constraints.filter_map(
@@ -733,7 +742,7 @@ fn symbol_from_bindings_impl<'db>(
733742
// code. However, it is still okay to return `Never` in this case, because we will
734743
// union the types of all bindings, and `Never` will be eliminated automatically.
735744

736-
if unbound_visibility.is_always_false() {
745+
if unbound_visibility().is_always_false() {
737746
// The scope-start is not visible
738747
return Some(Type::Never);
739748
}
@@ -762,7 +771,7 @@ fn symbol_from_bindings_impl<'db>(
762771
);
763772

764773
if let Some(first) = types.next() {
765-
let boundness = match unbound_visibility {
774+
let boundness = match unbound_visibility() {
766775
Truthiness::AlwaysTrue => {
767776
unreachable!("If we have at least one binding, the scope-start should not be definitely visible")
768777
}

crates/red_knot_python_semantic/src/types/infer.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7849,6 +7849,50 @@ mod tests {
78497849
check_typevar("Y", None, None, None);
78507850
}
78517851

7852+
/// Test that a symbol known to be unbound in a scope does not still trigger cycle-causing
7853+
/// visibility-constraint checks in that scope.
7854+
#[test]
7855+
fn unbound_symbol_no_visibility_constraint_check() {
7856+
let mut db = setup_db();
7857+
7858+
// If the bug we are testing for is not fixed, what happens is that when inferring the
7859+
// `flag: bool = True` definitions, we look up `bool` as a deferred name (thus from end of
7860+
// scope), and because of the early return its "unbound" binding has a visibility
7861+
// constraint of `~flag`, which we evaluate, meaning we have to evaluate the definition of
7862+
// `flag` -- and we are in a cycle. With the fix, we short-circuit evaluating visibility
7863+
// constraints on "unbound" if a symbol is otherwise not bound.
7864+
db.write_dedented(
7865+
"src/a.py",
7866+
"
7867+
from __future__ import annotations
7868+
7869+
def f():
7870+
flag: bool = True
7871+
if flag:
7872+
return True
7873+
",
7874+
)
7875+
.unwrap();
7876+
7877+
db.clear_salsa_events();
7878+
assert_file_diagnostics(&db, "src/a.py", &[]);
7879+
let events = db.take_salsa_events();
7880+
let cycles = salsa::plumbing::attach(&db, || {
7881+
events
7882+
.iter()
7883+
.filter_map(|event| {
7884+
if let salsa::EventKind::WillIterateCycle { database_key, .. } = event.kind {
7885+
Some(format!("{database_key:?}"))
7886+
} else {
7887+
None
7888+
}
7889+
})
7890+
.collect::<Vec<_>>()
7891+
});
7892+
let expected: Vec<String> = vec![];
7893+
assert_eq!(cycles, expected);
7894+
}
7895+
78527896
// Incremental inference tests
78537897
#[track_caller]
78547898
fn first_public_binding<'db>(db: &'db TestDb, file: File, name: &str) -> Definition<'db> {

fuzz/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ ruff_python_formatter = { path = "../crates/ruff_python_formatter" }
2929
ruff_text_size = { path = "../crates/ruff_text_size" }
3030

3131
libfuzzer-sys = { git = "https://github.com/rust-fuzz/libfuzzer", default-features = false }
32-
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "b165ba7bd1b2a0112ce574a082ab8ea5102252ac" }
32+
salsa = { git = "https://github.com/salsa-rs/salsa.git", rev = "87bf6b6c2d5f6479741271da73bd9d30c2580c26" }
3333
similar = { version = "2.5.0" }
3434
tracing = { version = "0.1.40" }
3535

0 commit comments

Comments
 (0)