Why doesn't the debugger stop on loop `break`?

I am debugging some Rust code, and inside an if condition, there was only a single line: break, which exits the loop.

I couldn't confirm whether the condition was true because the debugger didn't step onto the break line.

if e.kind() == std::io::ErrorKind::UnexpectedEof {
    println!("error = {:?}", e);
    println!("kind = {:?}", e.kind());
    break;
}

I had to add some println! statements to confirm that the condition was actually true.

Why does the debugger behave like this?

The direct cause is simply because it seems Rust attributes the jump to the if, note the lack of any associated instructions with the break line in this godbolt example:

In there the lines are assigned as:

; pub fn main() {
        push    rax
;    loop {
        jmp     .LBB0_1
;        before();
.LBB1_1:
        call    example[ceccecd8beb92274]::before
;        if maybe() {
        call    example[ceccecd8beb92274]::maybe
        test    al, 1
        jne     .LBB0_3
;            break;
;        }
;        after();
        call    example[ceccecd8beb92274]::after
        jmp     .LBB0_1
;    }
;}
.LBB0_3:
        pop     rax
        ret

Without any actual instruction to stop the processor on, you can't put a breakpoint or step on to the break.


But that only begs the question, why doesn't it assign the jne to the break?

This gets into the Rust compiler internals, unfortunately. The MIR (middle-intermediate representation for main here is (bb here means "basic block" - a sequence of instructions without any interior control flow):

fn main() -> () {
    let mut _0: ();
    let _1: ();
    let mut _2: bool;
    let _3: ();

    bb0: {
        goto -> bb1;
    }

    bb1: {
        _1 = before() -> [return: bb2, unwind continue];
    }

    bb2: {
        _2 = maybe() -> [return: bb3, unwind continue];
    }

    bb3: {
        switchInt(move _2) -> [0: bb5, otherwise: bb4];
    }

    bb4: {
        return;
    }

    bb5: {
        _3 = after() -> [return: bb1, unwind continue];
    }
}

This is roughly:

fn main() {
  bb1:
    before();
    goto bb2;

  bb2:
    let _2 = maybe();
    goto bb3;

  bb3:
    match _2 {
      false => goto bb5,
      _ => goto bb4,
    }

  bb4:
    return;

  bb5:
    after();
    goto bb1;
}

In other words, it's already "de-sugured" the if into a match and jump directly to the end of the function by the time it's done processing the surface syntax and starts considering the actual behavior - which makes sense because it doesn't actually matter which of these you write:

loop {
  if maybe() {
    foo();
    break;
  }
  bar()
}
// vs

loop {
  if !maybe() {
    bar();
    continue;
  }
  foo();
  break;
}

at the end of the day an if statement ends up as a conditional jump so if (cond) {break;} ends up simply setting the jump destination to the end of the outer block so there is no distinct break instruction for the debugger to stop at