Mehdi Akiki
Rust Failure Atlas / Language and diagnostics

RFA-632 · Case file with fixtures · Case 604 of 694 · Compiler evidence

Loop Labels Do Not Cross Closure or async Boundaries

A closure reports an outcome to its caller; it cannot jump into the caller's control flow. Return a value or ControlFlow, then let the loop decide whether to break.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all Rust targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
Lexical nesting was assumed to permit a non-local jump even though the closure is a separately callable body that must return an outcome.
First discriminating check
Return bool, Option, Result, or ControlFlow from the inner computation and let the code owning the loop break.

A loop label names a destination inside one control-flow body. A closure is a separately callable body, even when written between the label and its apparent use. It cannot jump back into the caller’s loop. The failing fixture attempts that jump and receives E0767.

Lexical nesting is not enough

Visually, the closure sits inside 'records: loop. Operationally, it can be stored and called later or from another location. A break 'records inside it would require a non-local jump into a particular active invocation of its creator.

The official E0767 explanation states that labels are not reachable through functions, closures, async blocks, or modules. The Reference describes loop labels and their lexical use.

I use the boundary as a useful API signal: the inner computation must return information; the owner of the loop performs the jump.

Return a decision to the loop

The repaired fixture makes the closure return bool, then breaks in the outer body. This is enough for a two-way decision.

For richer traversal I use Option, Result, or ControlFlow. ControlFlow::Continue(state) and ControlFlow::Break(output) describe early exit without pretending the callback owns its caller’s label.

Naming the result improves tests. I can test the closure or visitor independently with inputs that continue, stop successfully, or fail. The outer loop has a small separate test for how it handles each outcome.

Iterator adapters already encode this boundary

Callbacks passed to for_each cannot use break to exit the iteration. If early exit matters, find, find_map, position, any, all, try_for_each, or an ordinary for loop is usually the proper operation.

I choose the adapter from semantics rather than forcing side effects into for_each. try_for_each can propagate Result, while try_fold can combine state and short-circuit. An explicit loop remains excellent when several exit reasons or resource transitions need names.

This makes performance easier to inspect too. A clear loop or standard short-circuit adapter tells the compiler and reviewer when work stops. A shared mutable flag checked by every callback invocation often continues doing unnecessary work and complicates borrowing.

Async blocks make ownership more distinct

An async block creates a future that may run after the surrounding stack frame has advanced. It cannot break the creator’s loop. It returns an output when polled to completion, and the awaiting code decides its own control flow.

In concurrent loops, a task result may arrive out of order. “Break on first failure” then also requires a policy for cancellation and joining of other tasks. A label cannot express that distributed lifecycle.

I retain task handles, bound concurrency, and decide whether remaining work is cancelled, drained, or allowed to finish. The returned outcome includes enough identity to explain which item requested stopping.

Avoid encoding jumps through panics

Panicking from a closure and catching it outside is not a replacement for a labelled break. It interacts with unwinding, destructors, foreign boundaries, panic configuration, and observability. Panics represent violated assumptions or unrecoverable policy, not routine traversal results.

Shared flags can be appropriate across threads, but they are cooperative cancellation signals rather than jumps. Work checks the flag at defined points and may continue for a bounded delay. I document that latency and memory ordering where atomics are involved.

For parser visitors and tree walks, a typed outcome often becomes a stable extension point. New reasons such as “found,” “budget exhausted,” and “invalid input” can be added deliberately instead of hiding meaning in true and false.

My E0767 checklist

  • Is the target label separated by a closure, function, async block, or module?
  • What information should the inner computation return to its caller?
  • Is bool sufficient, or do Option, Result, or ControlFlow express the outcome better?
  • Would a short-circuiting iterator adapter replace for_each?
  • Does async or concurrent work need cancellation and join policy after stopping?
  • Are multiple exit reasons named and observable?
  • Is shared-state signalling being confused with immediate control transfer?
  • Can the decision producer and loop response be tested independently?

The core principle is that callable bodies return values; they do not jump into a caller’s labels. E0767 protects this boundary. I make the inner work report a typed decision and leave control flow, cancellation, and cleanup with the code that owns the loop.