RFA-526 · Case file with fixtures · Case 498 of 694 · Compiler evidence
Break Inside a Rust Closure Cannot Target an Outer Loop
Closures can capture values but cannot capture an enclosing loop's break target. Return from the closure, signal through data, or put the loop inside the closure that controls it.
- 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
- Closures capture environment values but not an enclosing loop's jump target, and they may be stored or invoked after that target has disappeared.
- First discriminating check
- Return a ControlFlow, Result, or domain signal for caller-owned traversal, or put the loop inside the closure when the closure genuinely owns iteration.
I wrote break inside a closure whose body contained no loop. Rust emitted E0267 because a closure creates a separate control-flow body and cannot jump to a loop outside it.
The failing fixture has no outer loop, but the same rule surprises people when a callback is created inside one.
Closures capture data, not jump targets
A closure may borrow or move variables from its environment. This lexical capture does not include the caller's loop stack, return destination, or execution point.
The closure can be stored and invoked later, after an enclosing loop has ended. Letting break target that loop would make its meaning depend on a dynamic invocation context Rust does not provide.
The official E0267 page limits break to loops contained inside the closure.
The repaired fixture owns its loop
The repaired fixture puts a for loop inside the closure, so break has a local target. Calling the closure executes and exits that loop normally.
This repair fits when iteration is truly the closure's responsibility. If the outer caller owns iteration, moving the loop changes architecture and may not be appropriate.
I choose a signalling design based on who controls traversal.
Return exits the closure, not its caller
return inside a closure returns from the closure body. It can stop callback work early, but it does not return from the surrounding function.
A closure can return a value such as ControlFlow, bool, or Option that the caller interprets. Iterator adapters like try_for_each support short-circuiting through a residual result.
The control request crosses the function boundary as data rather than a jump.
Labels do not cross closure boundaries
Naming an outer loop 'search does not make break 'search valid inside a closure. Labels identify targets within the same control-flow context, and the closure remains a boundary.
This is important in nested iterator code where adding a label looks like the obvious repair. I replace the callback abstraction or return a signal instead.
The loop expression Reference defines label and break behaviour.
Iterator methods have their own short-circuit tools
find, position, any, and all already stop once they know the answer. try_fold and try_for_each can propagate early completion or errors.
I prefer the adapter whose return value expresses my goal. Manually simulating break inside for_each is a common sign that for_each is the wrong iterator method.
A normal for loop is also perfectly clear when control flow is central.
Callback APIs should document stopping protocol
My own visitor API can let callbacks return ControlFlow<B, C> or an enum such as Visit::Continue and Visit::Stop(result). The caller remains responsible for interpreting it and ending traversal.
This explicit protocol works even when callbacks are stored, asynchronous, or invoked by another component. It also carries a reason or value without hidden non-local jumps.
Result propagation can double as early exit
When callback work can fail, returning Result gives the traversal a natural stopping signal. try_for_each stops on the first Err, and the caller decides whether to propagate, transform, or recover from it. I do not use a fake error only to simulate ordinary successful early completion; ControlFlow or a dedicated enum communicates that case better.
This separation matters for monitoring. A requested stop is not necessarily an error, and treating it as one can create false alerts. The callback result should distinguish completion, cancellation, and failure when operations need all three.
Async closures cannot escape their task through break
An async block or callback has an even clearer suspension boundary. It returns a future result; it cannot jump into the executor or its creator's loop. Cancellation tokens, channel messages, and returned control values are the appropriate protocols. I keep them explicit so cleanup and partial progress can be handled at await points.
My E0267 checklist
- Is the
breaktext inside a closure body? - Is its intended loop also inside that closure?
- Am I trying to target an enclosing labelled loop?
- Can the closure return a stop signal as data?
- Does an iterator adapter already provide short-circuiting?
- Would an ordinary
forloop make control flow clearer? - Does the callback API need a
ControlFlowresult? - Is
returnmeant to exit only the closure or the outer function?
The core principle is that closures share values with their environment, not control-flow destinations. Early exit crosses the closure boundary through a return value and an explicit caller protocol.