Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 32 additions & 7 deletions pyrefly/lib/binding/scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,8 @@ impl FlowStyle {
MergeStyle::Loop
| MergeStyle::LoopDefinitelyRuns
| MergeStyle::Exclusive
| MergeStyle::Inclusive => FlowStyle::PossiblyUninitialized,
| MergeStyle::Inclusive
| MergeStyle::Finally => FlowStyle::PossiblyUninitialized,
}
}
}
Expand Down Expand Up @@ -2945,6 +2946,10 @@ enum MergeStyle {
/// Distinct from [Branching] because we have to be more lax about
/// uninitialized locals (see `FlowStyle::merge` for details).
BoolOp,
/// This is the merge immediately before analyzing a `finally` block.
/// Terminating branches still contribute because `finally` executes before
/// their control-flow effect is observed outside the statement.
Finally,
}

impl MergeStyle {
Expand Down Expand Up @@ -3297,17 +3302,20 @@ impl<'a> BindingsBuilder<'a> {
return;
}

let use_all_branches = matches!(merge_style, MergeStyle::Finally);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge_flow short-circuits when branches.len() == 1, which means MergeStyle::Finally never gets a chance to apply its special handling in try/finally constructs with no except handlers. If the try branch terminates (e.g. return/raise), this makes the finally body appear unreachable and can produce incorrect “unreachable return” diagnostics inside finally. Consider disabling the single-branch short-circuit for MergeStyle::Finally, or otherwise ensuring the finally body is analyzed as reachable even when the pre-finally flow has terminated.

Copilot uses AI. Check for mistakes.
// We normally only merge the live branches (where control flow is not
// known to terminate), but if nothing is live we still need to fill in
// the Phi keys and potentially analyze downstream code, so in that case
// we'll use the terminated branches.
let (terminated_branches, live_branches): (Vec<_>, Vec<_>) =
branches.into_iter().partition(|flow| flow.has_terminated);
let has_terminated = live_branches.is_empty() && !merge_style.is_loop();
let flows = if has_terminated {
terminated_branches
let n_live_branches = branches.iter().filter(|flow| !flow.has_terminated).count();
let has_terminated = n_live_branches == 0 && !merge_style.is_loop();
let flows = if use_all_branches || has_terminated {
branches
} else {
live_branches
branches
.into_iter()
.filter(|flow| !flow.has_terminated)
.collect()
};
// Determine reachability of the merged flow.
// For Loop style with empty flows (all branches terminated), the loop body might
Expand All @@ -3319,6 +3327,11 @@ impl<'a> BindingsBuilder<'a> {
MergeStyle::Loop => base.is_definitely_unreachable,
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For MergeStyle::Finally, if all incoming branches have terminated (n_live_branches == 0), all_are_unreachable becomes true (via the flows.is_empty() / default path) and the merged flow will mark the start of the finally block as definitely unreachable. Semantically, finally still executes even when the try/except path terminates, so reachability inside finally should be treated as reachable, while preserving the termination effect for code after the try statement. A practical fix is to treat MergeStyle::Finally as reachable at the start of finally (even when all branches terminated) and then re-apply the termination/unreachability state after analyzing the finally body.

Suggested change
MergeStyle::Loop => base.is_definitely_unreachable,
MergeStyle::Loop => base.is_definitely_unreachable,
MergeStyle::Finally => false,

Copilot uses AI. Check for mistakes.
_ => true,
}
} else if use_all_branches && n_live_branches > 0 {
flows
.iter()
.filter(|f| !f.has_terminated)
.all(|f| f.is_definitely_unreachable)
} else {
flows.iter().all(|f| f.is_definitely_unreachable)
};
Expand Down Expand Up @@ -3623,6 +3636,18 @@ impl<'a> BindingsBuilder<'a> {
self.finish_fork_impl(None, false, None)
}

/// Finish an exhaustive fork for a `finally` block. Unlike a normal merge,
/// branches that have already terminated still contribute to the merged type
/// state because they will execute the `finally` body first.
pub fn finish_finally_fork(&mut self) {
let fork = self.scopes.current_mut().forks.pop().unwrap();
assert!(
!fork.branch_started,
"A branch is started - did you forget to call `finish_branch`?"
);
self.merge_flow(fork.base, fork.branches, fork.range, MergeStyle::Finally);
}

/// Finish a non-exhaustive fork in which the base flow is part of the merge. It negates
/// the branch-choosing narrows by applying `negated_prev_ops` to base before merging, which
/// is important so that we can preserve any cases where a termanating branch has permanently
Expand Down
3 changes: 1 addition & 2 deletions pyrefly/lib/binding/stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1184,11 +1184,10 @@ impl<'a> BindingsBuilder<'a> {
// https://docs.python.org/3/reference/compound_stmts.html#except-clause
self.scopes.mark_as_deleted(&name.id);
}

self.finish_branch();
}

self.finish_exhaustive_fork();
self.finish_finally_fork();
self.scopes.enter_finally();
self.stmts(x.finalbody, parent);
self.scopes.exit_finally();
Expand Down
27 changes: 27 additions & 0 deletions pyrefly/lib/test/flow_branching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,33 @@ def main(resolve: bool) -> None:
"#,
);

// Issue #2845: `finally` must see both the successful `try` state and terminating `except` state.
testcase!(
test_try_finally_preserves_pre_try_possibility_from_terminating_except,
r#"
from typing import assert_type

def something_that_might_throw() -> None:
raise Exception()

class Thing:
def something(self) -> None:
pass

def blah() -> None:
x = None
try:
something_that_might_throw()
if x is None:
x = Thing()
except Exception:
raise
finally:
assert_type(x, Thing | None)
x.something() # E: Object of class `NoneType` has no attribute `something`
"#,
);

Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds good coverage for mixing a live try path with a terminating except path, but the new MergeStyle::Finally behavior also needs coverage for try/finally with no handlers (single branch), especially when the try path terminates (return/raise). Without a test, it’s easy to regress by marking the finally body unreachable or by skipping the finally-specific merge logic due to single-branch short-circuiting.

Suggested change
// Issue #2845: `finally` must also handle single-branch try/finally where the try terminates.
testcase!(
test_try_finally_preserves_pre_try_possibility_from_terminating_try_no_except,
r#"
from typing import assert_type
class Thing:
def something(self) -> None:
pass
def blah() -> None:
x = None
try:
if x is None:
x = Thing()
return
finally:
assert_type(x, Thing | None)
x.something() # E: Object of class `NoneType` has no attribute `something`
"#,
);

Copilot uses AI. Check for mistakes.
// for https://github.com/facebook/pyrefly/issues/1840
testcase!(
test_exhaustive_flow_no_fall_through,
Expand Down
Loading