Skip to content

Commit 40869b5

Browse files
authored
fix(noUnreachable): handle dead implicit jumps in finally (#9305)
1 parent 753be72 commit 40869b5

File tree

4 files changed

+383
-74
lines changed

4 files changed

+383
-74
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Fixed [#4946](https://github.com/biomejs/biome/issues/4946): `noUnreachable` no longer reports code inside `finally` blocks as unreachable when there is a `break`, `continue`, or `return` in the corresponding `try` body.

crates/biome_js_analyze/src/lint/correctness/no_unreachable.rs

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -422,25 +422,36 @@ struct PathState<'cfg> {
422422
next_block: BlockId,
423423
/// Set of all blocks already visited on this path
424424
visited: RoaringBitmap,
425-
/// Current terminating instruction for the path, if one was
426-
/// encountered
427-
terminator: Option<Option<PathTerminator>>,
425+
/// Current terminating instruction for the path, if one was encountered.
426+
/// See [`Terminator`] for the semantics of each `Option` level.
427+
terminator: Option<Terminator>,
428428
exception_handlers: Option<&'cfg [ExceptionHandler]>,
429+
/// The terminator that was active before entering a `finally` block via a
430+
/// dead implicit jump (i.e. when `break`/`continue`/`return`/`throw`
431+
/// precedes the implicit try-body-to-finally fall-through).
432+
///
433+
/// `None` means we are not currently inside such a finally block.
434+
/// `Some(t)` stores the `Option<Terminator>` that was in `self.terminator`
435+
/// when the finally block was entered; it is restored by `finally_fallthrough`
436+
/// so that code *after* the `try/finally` construct is still correctly seen
437+
/// as unreachable.
438+
pre_finally_terminator: Option<Option<Terminator>>,
429439
}
430440

431441
/// Perform a simple reachability analysis on the control flow graph by
432442
/// traversing the function starting at the entry points
433443
fn traverse_cfg(
434444
cfg: &JsControlFlowGraph,
435445
signals: &mut UnreachableRanges,
436-
) -> FxHashMap<BlockId, Vec<Option<Option<PathTerminator>>>> {
446+
) -> FxHashMap<BlockId, Vec<Option<Terminator>>> {
437447
let mut queue = VecDeque::new();
438448

439449
queue.push_back(PathState {
440450
next_block: ROOT_BLOCK_ID,
441451
visited: RoaringBitmap::new(),
442452
terminator: None,
443453
exception_handlers: None,
454+
pre_finally_terminator: None,
444455
});
445456

446457
// This maps holds a list of "path state", the active terminator
@@ -480,6 +491,7 @@ fn traverse_cfg(
480491
visited: path.visited.clone(),
481492
terminator: path.terminator,
482493
exception_handlers: find_catch_handlers(handlers),
494+
pre_finally_terminator: None,
483495
});
484496
}
485497
}
@@ -495,10 +507,34 @@ fn traverse_cfg(
495507
InstructionKind::Statement => {}
496508
InstructionKind::Jump {
497509
conditional,
498-
block,
510+
block: jump_target,
499511
finally_fallthrough,
500512
} => {
501-
handle_jump(&mut queue, &path, block, finally_fallthrough);
513+
// If we are in dead code (has_direct_terminator) but the jump
514+
// target is a cleanup (finally) handler for this block, the finally
515+
// block is still reachable — it always runs before any early exit
516+
// (break/continue/return/throw). Clear the terminator so the finally
517+
// block is not incorrectly reported as unreachable. Store the
518+
// original terminator in `pre_finally_terminator` so it can be
519+
// restored when the finally block exits via `finally_fallthrough`.
520+
let (effective_terminator, pre_finally_terminator) = if has_direct_terminator
521+
&& block
522+
.cleanup_handlers
523+
.iter()
524+
.any(|h| h.target == jump_target)
525+
{
526+
(None, Some(path.terminator))
527+
} else {
528+
(path.terminator, None)
529+
};
530+
handle_jump(
531+
&mut queue,
532+
&path,
533+
jump_target,
534+
finally_fallthrough,
535+
effective_terminator,
536+
pre_finally_terminator,
537+
);
502538

503539
// Jump is a terminator instruction if it's unconditional
504540
if path.terminator.is_none() && !conditional {
@@ -565,12 +601,20 @@ fn find_catch_handlers(handlers: &[ExceptionHandler]) -> Option<&[ExceptionHandl
565601
}
566602
}
567603

568-
/// Create an additional visitor path from a jump instruction and push it to the queue
604+
/// Create an additional visitor path from a jump instruction and push it to the queue.
605+
/// `effective_terminator` is the terminator to propagate to the target block; callers may
606+
/// pass `None` when the target is known to be reachable regardless of any prior terminator
607+
/// (e.g. an implicit jump from the end of a try body to its finally block).
608+
/// `pre_finally_terminator` should be set when `effective_terminator` was cleared to allow
609+
/// a finally block to appear reachable; it stores the original terminator so it can be
610+
/// restored when the finally block exits via `finally_fallthrough`.
569611
fn handle_jump<'cfg>(
570612
queue: &mut VecDeque<PathState<'cfg>>,
571613
path: &PathState<'cfg>,
572614
block: BlockId,
573615
finally_fallthrough: bool,
616+
effective_terminator: Option<Terminator>,
617+
pre_finally_terminator: Option<Option<Terminator>>,
574618
) {
575619
// If this jump is exiting a finally clause and this path is visiting
576620
// an exception handlers chain
@@ -587,16 +631,45 @@ fn handle_jump<'cfg>(
587631
visited: path.visited.clone(),
588632
terminator: path.terminator,
589633
exception_handlers: Some(handlers),
634+
pre_finally_terminator: None,
635+
});
636+
}
637+
} else if finally_fallthrough {
638+
// Exiting a finally block in the normal (non-exception) context.
639+
// Determine the terminator to propagate past the try/finally:
640+
// - If the finally block itself terminates (e.g. contains a `return`
641+
// or `throw`), prefer that as the most proximate cause.
642+
// - Otherwise, if we entered finally via a dead implicit jump (the
643+
// try body had `break`/`continue`/`return`), restore the saved
644+
// pre-finally terminator so code after the try/finally is still
645+
// correctly seen as unreachable.
646+
let continuation_terminator = path
647+
.terminator
648+
.or(path.pre_finally_terminator.unwrap_or(None));
649+
650+
if !path.visited.contains(block.index()) {
651+
queue.push_back(PathState {
652+
next_block: block,
653+
visited: path.visited.clone(),
654+
terminator: continuation_terminator,
655+
exception_handlers: path.exception_handlers,
656+
pre_finally_terminator: None,
590657
});
591658
}
592659
} else if !path.visited.contains(block.index()) {
593660
// Push the jump target block to the queue if it hasn't
594-
// been visited yet in this path
661+
// been visited yet in this path.
662+
// Propagate `pre_finally_terminator` from the current path so it
663+
// survives across internal jumps inside a finally block (e.g. an
664+
// if/else inside finally) and is available when the block exits via
665+
// `finally_fallthrough`.
666+
let effective_pre_finally = pre_finally_terminator.or(path.pre_finally_terminator);
595667
queue.push_back(PathState {
596668
next_block: block,
597669
visited: path.visited.clone(),
598-
terminator: path.terminator,
670+
terminator: effective_terminator,
599671
exception_handlers: path.exception_handlers,
672+
pre_finally_terminator: effective_pre_finally,
600673
});
601674
}
602675
}
@@ -616,6 +689,7 @@ fn handle_return<'cfg>(
616689
visited: path.visited.clone(),
617690
terminator: path.terminator,
618691
exception_handlers: Some(handlers),
692+
pre_finally_terminator: None,
619693
});
620694
}
621695
}
@@ -967,6 +1041,16 @@ pub struct UnreachableRange {
9671041
terminators: Vec<PathTerminator>,
9681042
}
9691043

1044+
/// The terminator state of a path through the control flow graph.
1045+
///
1046+
/// Each level of `Option` carries distinct semantics:
1047+
/// - Outer `Option`: `None` = the path has no terminator yet (code is still live);
1048+
/// `Some(_)` = a terminating instruction has been encountered on this path.
1049+
/// - Inner `Option<PathTerminator>`: `None` = the terminator has no associated
1050+
/// syntax node (e.g. an implicit jump); `Some(pt)` = the terminator corresponds
1051+
/// to a concrete syntax node that can be pointed to in a diagnostic.
1052+
type Terminator = Option<PathTerminator>;
1053+
9701054
#[derive(Debug, Clone, Copy)]
9711055
struct PathTerminator {
9721056
kind: JsSyntaxKind,

crates/biome_js_analyze/tests/specs/correctness/noUnreachable/JsTryFinallyStatement.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// should generate diagnostics
2+
13
function JsTryFinallyStatement1() {
24
try {
35
tryBlock();
@@ -58,3 +60,63 @@ function JsTryFinallyStatement4() {
5860

5961
afterTryStatement();
6062
}
63+
64+
// https://github.com/biomejs/biome/issues/4946
65+
// finally block should be reachable when there is a jump (break/continue/return) before it
66+
function JsTryFinallyStatement5() {
67+
while (true) {
68+
try {
69+
break;
70+
} finally {
71+
console.log("reachable");
72+
}
73+
console.log("unreachable");
74+
}
75+
}
76+
77+
function JsTryFinallyStatement6() {
78+
while (true) {
79+
try {
80+
continue;
81+
} finally {
82+
console.log("reachable");
83+
}
84+
console.log("unreachable");
85+
}
86+
}
87+
88+
function JsTryFinallyStatement7() {
89+
try {
90+
return;
91+
} finally {
92+
console.log("reachable");
93+
}
94+
console.log("unreachable");
95+
}
96+
97+
// finally itself contains a return: code after the try/finally is unreachable
98+
// due to the finally's return (not the try's return)
99+
function JsTryFinallyStatement8() {
100+
try {
101+
return 1;
102+
} finally {
103+
return 2;
104+
}
105+
console.log("unreachable");
106+
}
107+
108+
// nested try/finally: both inner and outer finally blocks should be reachable
109+
function JsTryFinallyStatement9() {
110+
while (true) {
111+
try {
112+
try {
113+
break;
114+
} finally {
115+
console.log("inner finally reachable");
116+
}
117+
} finally {
118+
console.log("outer finally reachable");
119+
}
120+
console.log("unreachable");
121+
}
122+
}

0 commit comments

Comments
 (0)