fix(linter/eslint/no-unreachable-loop): do not report loops whose body has a finally block (#25335)

## What this fixes

`no-unreachable-loop` reports any loop whose body contains a `finally`
block, even when the body plainly runs to the next iteration:

```js
// all reported today, none reported by ESLint
while (a) { try { foo(); } finally { bar(); } }
while (a) { try { foo(); } finally {} }
while (a) { try { foo(); } catch (e) { bar(); } finally { baz(); } }
for (const x of xs) { try { foo(x); } finally { bar(x); } }
for (let i = 0; i < a; i++) { try { foo(); } finally { bar(); } }
```

The trigger is `finally` specifically — `try`/`catch` without a
finalizer has been correct since #25071. This is the same family as
#24251 (`switch`) and #25048 (`catch`); `try`/`finally` in a loop is a
common enough idiom (cleanup around an awaited call) that it makes the
rule unusable on real code.

## Cause

For `while (a) { try { foo(); } finally { bar(); } }` the CFG is:

```
bb3 (loop body) --Normal--> bb5 (try block)
bb5 --Finalize--> bb4 --Normal--> bb6 (finally block)
bb6 --Join--> bb7 (empty, after the try) --Backedge--> bb2 (loop test)
```

`has_next_iteration_path` never reaches bb7, for two reasons:

1. The `Finalize` arm only consults `finalizer_can_continue_to_loop`,
which returns `true` solely when the finalizer contains an explicit
`continue`. A block that completes normally has no other outgoing edge,
so the search stops at bb5.
2. `EdgeType::Join` was ignored, and `is_synthetic_continuation` only
followed incoming `Normal`/`Jump` edges. `Join` is exactly the edge from
the end of a finalizer to the statement after the `try`, so even
reaching bb6 would not have found the backedge.

## The change

- `Finalize` edge: keep the existing `continue` check, and additionally
follow the edge when the protected block completes normally. That is
sound because a normally-completing block has no other way out — control
runs the finalizer and resumes after the `try`. When the block instead
exits abruptly, the finalizer resumes that exit, so the pre-existing
`continue` check remains the only way it reaches the next iteration.
- `Join` edge: follow it. It is only reachable through a `Finalize`
edge, which is now gated as above, so it cannot resurrect an abrupt
path.
- `is_synthetic_continuation`: accept an incoming `Join`, so the empty
after-finalizer block is recognised as part of the loop body. This is
what makes the three-part `for` work, where the backedge hops through
the update block.

New helper `block_completes_normally` — no
`break`/`continue`/`return`/`throw` instruction in the block. An
implicit throw is an error edge rather than an instruction, so it
correctly does not count as abrupt.

## Tests

13 pass cases and 7 fail cases added. The fail cases pin the direction
that must not regress — the finalizer runs and then resumes the abrupt
exit that entered it:

```js
function f() { while (a) { try { foo(); } finally { return; } } }   // still reported
while (a) { try { foo(); } finally { break; } }                     // still reported
function f() { while (a) { try { return; } finally { bar(); } } }   // still reported
while (a) { try { throw err; } finally { bar(); } }                 // still reported
```

- `cargo test -p oxc_linter` — 1221 passed, 0 failed. The snapshot diff
is purely additive (the 7 new fail cases); no existing diagnostic
changed.
- `cargo clippy -p oxc_linter --all-targets -- -D warnings` clean,
`cargo fmt` applied.
- Differential check against `eslint@9.39.5` on 22 hand-written
`try`/`finally`-in-loop shapes (plain, empty finalizer, with `catch`,
nested, labelled `continue`, `do`/`while`, `for`, `for…in`, `for…of`,
abrupt exits in either block): **before the fix oxlint reported 18 of 22
and ESLint 7; after the fix both report the same 7.**
- Real-world regression sweep: 179 TypeScript repos (~9k files in the
largest, full rule sets, including `jsPlugins`) linted with
`oxlint@1.77.0` from npm and with this build. 500 diagnostics → 496.
Every one of the 4 removed is a `try`/`finally` false positive; **no
repo gained a diagnostic.**

## AI usage disclosure

Per the AI usage policy in `CONTRIBUTING.md`: **this fix was developed
with AI assistance (Claude Code).** That covers the CFG diagnosis, the
code change, the added test vectors and the two validation harnesses
described above. I have reviewed the diff and the results, and I own
this contribution.

The validation was built specifically so the change is not taken on
trust: the differential run pins behaviour against ESLint as the
reference implementation, and the 179-repo sweep checks that nothing
outside `try`/`finally` moved.
This commit is contained in:
Todor Andonov authored and GitHub committed 2026-08-07 10:37:10 +01:00
1 parent 9a37c418cd
commit 2963d989c8
2 files changed
+179 -2

No files matched your search

@@ -285,8 +285,14 @@ fn has_next_iteration_path(
}
EdgeType::Unreachable
| EdgeType::NewFunction
| EdgeType::Join
| EdgeType::Error(ErrorEdgeKind::Implicit) => {}
// Convergence point after a finalizer completes, i.e. the
// statement following the `try`. Only reachable through a
// `Finalize` edge, which is gated below, so following it here
// cannot resurrect an abrupt path.
EdgeType::Join => {
stack.push(edge.target());
}
EdgeType::Finalize => {
if finalizer_can_continue_to_loop(
edge.target(),
@@ -297,6 +303,24 @@ fn has_next_iteration_path(
) {
return true;
}
// Control runs the finalizer and resumes after the `try`.
// When the block instead exits abruptly the finalizer
// resumes that exit, so the only way it reaches the next
// iteration is the `continue` the check above looks for.
//
// The builder attaches a `Finalize` edge to every block
// under the finalizer, not just the protected region's
// fallthrough exit, so `source` can be an intermediate
// block (an `if` condition, say) whose every branch still
// exits abruptly. That does not leak a next-iteration path:
// when the protected region cannot complete normally the
// builder marks the block after the `try` unreachable and
// emits `Unreachable` in place of `Join`, which this search
// does not follow. See the `try`/`finally` fail cases.
if block_completes_normally(source, ctx) {
stack.push(edge.target());
}
}
EdgeType::Error(ErrorEdgeKind::Explicit) => {
if explicit_error_edge_can_throw(source, ctx) {
@@ -700,7 +724,12 @@ fn is_synthetic_continuation(
for edge in graph
.edges_directed(current, Direction::Incoming)
.filter(|edge| matches!(edge.weight(), EdgeType::Normal | EdgeType::Jump))
// `Join` reaches the empty block the builder emits after a
// finalizer. Without it, the block that resumes the loop after a
// `try`/`finally` looks like it belongs to nothing.
.filter(|edge| {
matches!(edge.weight(), EdgeType::Normal | EdgeType::Jump | EdgeType::Join)
})
{
let source = edge.source();
if is_unreachable_block(source, ctx, unreachable) {
@@ -723,6 +752,21 @@ fn is_synthetic_continuation(
false
}
/// `true` when control can fall off the end of `block_id` instead of leaving it
/// through `break`, `continue`, `return` or `throw`. An implicit throw is an
/// error edge rather than an instruction, so it does not count as abrupt here.
fn block_completes_normally(block_id: BlockNodeId, ctx: &LintContext<'_>) -> bool {
!ctx.cfg().basic_block(block_id).instructions().iter().any(|instruction| {
matches!(
instruction.kind,
InstructionKind::Break(_)
| InstructionKind::Continue(_)
| InstructionKind::Return(_)
| InstructionKind::Throw
)
})
}
fn owns_block(block_id: BlockNodeId, loop_id: NodeId, ctx: &LintContext<'_>) -> bool {
directly_owns_block(block_id, loop_id, ctx)
|| empty_backedge_targets_loop(block_id, loop_id, ctx)
@@ -961,6 +1005,34 @@ fn test() {
("while(true); while(true) break;", None).into(),
("while (true) {} while (foo) break;", None).into(),
("while (a) { try { continue; } finally {} }", None).into(),
// A `try` body that completes normally runs the finalizer and then
// resumes after the `try`, so every loop below iterates.
("while (a) { try { foo(); } finally { bar(); } }", None).into(),
("while (a) { try { foo(); } finally {} }", None).into(),
("while (a) { try { foo(); } catch (e) { bar(); } finally { baz(); } }", None).into(),
("while (a) { try { foo(); } finally { bar(); } baz(); }", None).into(),
("function f() { while (a) { try { foo(); } catch (e) { return; } finally { bar(); } } }", None)
.into(),
("while (a) { try { foo(); } finally { continue; } }", None).into(),
("function f() { while (a) { try { foo(); } finally { if (a) continue; else return; } } }", None)
.into(),
("while (a) { try { try { foo(); } finally { bar(); } } finally { baz(); } }", None).into(),
// A branch that can fall through keeps the loop alive, even when a
// sibling branch exits.
("function f() { while (a) { try { if (b) { return; } } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { if (b) return; foo(); } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { if (b) continue; else return; } finally { bar(); } } }", None)
.into(),
// Nothing falls out of the `try`, but the `catch` completes normally.
("function f() { while (a) { try { if (b) return; else return; } catch (e) {} finally { bar(); } } }", None)
.into(),
("for (const x of xs) { try { foo(x); } finally { bar(x); } }", None).into(),
("for (let i = 0; i < a; i++) { try { foo(); } finally { bar(); } }", None).into(),
("for (const k in a) { try { foo(); } finally { bar(); } }", None).into(),
("do { try { foo(); } finally { bar(); } } while (a);", None).into(),
("outer: while (a) { try { foo(); } finally { continue outer; } }", None).into(),
("while (a) { try { break; } finally { continue; } }", None).into(),
("while (a) { try { break; } finally { if (foo) continue; } }", None).into(),
("while (a) { try { throw err; } finally { continue; } }", None).into(),
@@ -1017,6 +1089,27 @@ fn test() {
}
}
fail.extend([
// The finalizer runs, then resumes the abrupt exit that entered it.
// The `if`/`switch`/inner-loop shapes matter because the builder gives
// every block under the finalizer a `Finalize` edge, so the search also
// sees intermediate blocks that carry no abrupt instruction themselves.
("function f() { while (a) { try { if (b) return; else return; } finally { bar(); } } }", None)
.into(),
("while (a) { try { if (b) break; else break; } finally { bar(); } }", None).into(),
("function f() { while (a) { try { if (b) return; else throw err; } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { switch (b) { case 1: return; default: return; } } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { for (;;) { return; } } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { foo(); } finally { return; } } }", None).into(),
("while (a) { try { foo(); } finally { break; } }", None).into(),
("function f() { while (a) { try { return; } finally { bar(); } } }", None).into(),
("while (a) { try { break; } finally { bar(); } }", None).into(),
("while (a) { try { throw err; } finally { bar(); } }", None).into(),
("function f() { while (a) { try { foo(); } finally { bar(); } return; } }", None).into(),
("function f() { while (a) { try { return; } catch (e) { return; } finally { bar(); } } }", None)
.into(),
("function f() { while (a) { try { ; return; } catch { continue; } } }", None).into(),
("function f() { while (a) { try { let value = 1; return; } catch { continue; } } }", None)
.into(),
@@ -5686,6 +5686,90 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { if (b) return; else return; } finally { bar(); } } }
· ────────────────────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:1]
1 │ while (a) { try { if (b) break; else break; } finally { bar(); } }
· ──────────────────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { if (b) return; else throw err; } finally { bar(); } } }
· ───────────────────────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { switch (b) { case 1: return; default: return; } } finally { bar(); } } }
· ────────────────────────────────────────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:34]
1 │ function f() { while (a) { try { for (;;) { return; } } finally { bar(); } } }
· ────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { foo(); } finally { return; } } }
· ────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:1]
1 │ while (a) { try { foo(); } finally { break; } }
· ───────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { return; } finally { bar(); } } }
· ────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:1]
1 │ while (a) { try { break; } finally { bar(); } }
· ───────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:1]
1 │ while (a) { try { throw err; } finally { bar(); } }
· ───────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { foo(); } finally { bar(); } return; } }
· ───────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { return; } catch (e) { return; } finally { bar(); } } }
· ──────────────────────────────────────────────────────────────────────
╰────
help: Remove the loop or make at least one path continue to the next iteration.
⚠ eslint(no-unreachable-loop): Invalid loop. Its body allows only one iteration.
╭─[no_unreachable_loop.tsx:1:16]
1 │ function f() { while (a) { try { ; return; } catch { continue; } } }