I’d like to inquire about a design problem that has been bothering me for the some time now - the Label escape problem and the future of the capability-based APIs.
I think that for a complete understanding I need to provide some context. The story begins with NonFatal - the guard that decides which exceptions Try and Future are allowed to catch (among other things):
object NonFatal:
def apply(t: Throwable): Boolean = t match
case _: VirtualMachineError | _: ThreadDeath | _: InterruptedException
| _: LinkageError | _: ControlThrowable => false
case _ => true
def unapply(t: Throwable): Option[Throwable] = if apply(t) then Some(t) else None
This design was made, at least in my understanding, to allow Future.apply to safely capture exceptions* thrown in its body. NonFatal carves out ControlThrowable - exceptions that represent control flow, not errors - and lets them pass through uncaught. This mechanic was used in scala.util.control.Breaks.
When boundary/break was introduced as the replacement for scala.util.control.NonLocalReturns and scala.util.control.Breaks, the Break exception was made to extend RuntimeException, not ControlThrowable. I don’t know the exact reason for this choice, but one likely factor is that Future uses Try internally, and making Break a ControlThrowable would have caused it to propagate through Future in rather unsavory ways*. Whatever the reason, this decision means that Try and Future catch Break exceptions.
boundary/break works by throwing a Break exception that carries a label. The boundary block’s try/catch matches the label and returns the break’s value. This design is simple and elegant - until another exception handler intercepts the Break first.
The problem:
Since Break extends RuntimeException and is NonFatal, Try catches it. Consider:
//> using scala 3.8.2
import scala.util.boundary, boundary.break
import scala.util.{Try, Success, Failure}
// Case 1: .get re-throws, so break reaches boundary - works by accident
val accidental = boundary:
val t = Try { break(42); 0 }
println(s" Try result: $t") // Failure(scala.util.boundary$Break)
t.get + 1 // .get re-throws Break, boundary catches it
// result: 42 - correct, but only by the happy accident of user calling .get
// Case 2: .getOrElse swallows the Break - break is silently lost
val swallowed = boundary:
val t = Try { break(42); 0 }
t.getOrElse(-1)
// result: -1, not 42!
// Case 3: pattern matching on Try - break is silently lost
val matched = boundary:
Try { break(42); 0 } match
case Success(v) => v
case Failure(_) => -1
// result: -1, not 42!
// Case 4: transform on Try - break is silently lost
val stringified = boundary:
Try { break(42); 0 }.transform(
i => Try(i.toString()),
t => Try(t.toString())
).get
// result: "scala.util.boundary$Break", not 42!
Running this produces:
Case 1 (.get re-throws): result = 42
Case 2 (.getOrElse): result = -1
Case 3 (pattern match): result = -1
Case 4 (transform): result = "scala.util.boundary$Break"
Case 1 works only because .get re-throws the Break, and the boundary catches it. Cases 2, 3 and 4 demonstrate the real danger: the Break is silently swallowed. The developer intended break(42) to exit the boundary with 42, but Try intercepted the exception and the break was lost. There is no warning, no error - just wrong behavior.
The same problem extends to Future, which uses Try internally. A break() inside a Future body fires on the executor thread, where the boundary’s try/catch is not on the call stack. The Break is caught by the Future machinery and turned into a failed Future. The boundary never sees it.
This is a class of bug that in my opinion capture checking was designed to prevent.
The solution?
Capture checking extends the Scala type system to track references to capabilities in values. The key idea is that capabilities - objects that grant access to effects - have bounded lifetimes, and the type system enforces that they don’t escape their intended scope.
The boundary/break API was designed with capture checking in mind. Looking at the implementation in scala.util.boundary:
final class Label[-T] extends caps.Control
Label extends caps.Control. In the capture checking hierarchy, Control sits here:
Capability
/ \
SharedCapability ExclusiveCapability
---------------- |
| Stateful
Control |
------- Unscoped
Control extends SharedCapability and is a Classifier. This means:
- Labels are shared capabilities - they can be freely aliased (multiple closures can use the same label).
- Labels are classified as
Control- they sit alongsideCanThrow(checked exceptions) andAsync(Gears concurrency) in the same family. - As
SharedCapability, labels cannot capture exclusive capabilities (mutable state, I/O). They are pure control flow markers.
The boundary.apply method has this signature:
inline def apply[T](inline body: Label[T] ?=> T): T
The return type is T - a type that cannot capture the label. This is escape checking: if you tried to return a closure that captures the label, the compiler would reject it because the label is locally scoped and can’t appear in an outer capture set.
This handles the case where a label escapes in the return type. But what about lateral escape - where the label is captured by code that runs in a different exception-handling context?
Escape checking prevents this:
// Rejected: label escapes in the return type
val later = boundary { label ?=> () => break(42) }
But it does not prevent this:
// Accepted: label escapes laterally into Try
boundary:
val t = Try { break(42); 0 }
t.getOrElse(-1) // break silently lost
The label doesn’t escape in the return type - boundary still returns an Int. The label escapes sideways into Try’s body, which runs in a different exception-handling context (Try’s own try/catch). The Break exception is caught by Try, not by boundary.
The natural question is: can we fix Try’s signature to prevent this?
Radical solution - pure functions
Under capture checking, => T (impure by-name) is () ->{cap} T - the body can capture any capability. The pure alternative is -> T, which is () -> T - the body cannot capture any capability at all.
extension (t: Try.type)
def safe[T](body: -> T): Try[T] = Try(body)
This works. Attempting to use break inside Try.safe is rejected:
Found: () ?->{local} Int
Required: () ?-> Int
Note that capability local is not included in capture set {}.
The label is a capability, the pure body can’t capture it, compilation fails. Does this solve the problem? Not really. A pure body can’t capture any capability. This makes Try.safe useless for real-world code that needs to catch exceptions from effectful computations. You can’t use logging, I/O, database connections, async capabilities, or anything else that’s modeled as a capability inside Try.safe. The only things it can wrap are pure arithmetic and pre-existing exception throws.
Granular solution - Classifiers to the rescue?
The capture checking system provides classifier restrictions with the only syntax. In fact, the capture-checking-aware signature of Try.apply shown in the capture-checking docs is:
object Try:
def apply[T](body: => T): Try[T]^{body.only[Control]}
But this restricts what the result captures, not what the body is allowed to use. The body can still capture labels; the restriction only says that the resulting Try object retains only Control capabilities from the body. The Break is still thrown inside Try’s try/catch and intercepted before it reaches boundary. The problem persists:
//> using scala 3.8.2
import language.experimental.captureChecking
import scala.util.boundary, boundary.break
import scala.util.{Try, Success, Failure}
import caps.Control
// Simulation of the CC-aware Try.apply signature from the docs:
// def apply[T](body: => T): Try[T]^{body.only[Control]}
extension (t: Try.type)
def cc[T](body: => T): Try[T]^{body.only[Control]} = Try(body)
// Case 1: .get re-throws
val case1 = boundary:
val t = Try.cc { break(42); 0 }
t.get + 1
println(s"Case 1 (.get): $case1")
// Case 2: .getOrElse
val case2 = boundary:
val t = Try.cc { break(42); 0 }
t.getOrElse(-1)
println(s"Case 2 (.getOrElse): $case2")
// Case 3: pattern match
val case3 = boundary:
Try.cc { break(42); 0 } match
case Success(v) => v
case Failure(_) => -1
println(s"Case 3 (match): $case3")
// Case 4: transform
val case4 = boundary:
Try.cc { break(42); 0 }.transform(
i => Try(i.toString()),
t => Try(t.toString())
).get
println(s"Case 4 (transform): $case4")
The unfortunate reality is that this yields the same result set:
Case 1 (.get): 42
Case 2 (.getOrElse): -1
Case 3 (match): -1
Case 4 (transform): scala.util.boundary$Break
This made me think about what would it take to actually express what I (and hopefully - we) want: could we use explicit capture set bounds to exclude labels?
def safeTry[T, C^ <: ???](body: () ->{C} T): Try[T]
The problem is that the classifier system only provides positive bounds. You can say C^ <: {someClassifier} to restrict to a classifier, but there’s no negation - no C^ <:!: {Control}, no .except[Control]. The hierarchy has Label, CanThrow, Async, and user-defined capabilities all under Control/SharedCapability, with no finer boundary between “safe to use inside Try” and “unsafe to use inside Try”.
In short: the only capture-checking tool that prevents label escape into Try is -> T, and it’s too blunt - it prevents all capabilities, not just labels.
Now I want to shortly talk about why this bothers me - the problem isn’t limited to Try and Future. Ox, the structured concurrency library for Scala developed at VL/SML, faces the same challenge.
Ox’s either block is boundary/break under the hood - .ok() on a Left throws a Break to short-circuit the computation. Ox’s supervised/fork provides structured concurrency where forked computations run on virtual threads.
When either and fork are composed, the question is whether .ok() inside a forked block correctly propagates back to either’s boundary. Now the design of Ox makes this issue a bit more complex because a lot of effort was put by Adam Warski to ensure Ox really has nice structured concurrency semantics. For example, with forkUser, the supervised scope waits for all userland forks before exiting. If a fork throws a Break, it propagates through supervised back to the either boundary:
//> using scala 3.8.2
//> using dep com.softwaremill.ox::core:1.0.4
import ox.*
import ox.either.ok
@main def oxExample =
val result: Either[String, Int] = either:
supervised:
forkUser:
val v: Int = Left("short-circuited from fork").ok()
v + 1
Right(42).ok()
// result: Left("short-circuited from fork") - correct!
println(result)
This works because ox’s structured concurrency guarantees that exceptions from user forks are rethrown at the supervision boundary. The nesting order is critical here: either is the outer scope, supervised is inside it, and when supervised rethrows a Break from a fork, the rethrow lands inside either’s boundary where it can be caught.
This runtime guarantee does not, however, prevent all of the aforementioned problems. The nesting order matters enormously, and when it’s wrong the result is not a silent swallow but an application crash. Consider the flipped nesting:
//> using scala 3.8.2
//> using dep com.softwaremill.ox::core:1.0.4
import ox.*
import ox.either.ok
@main def oxSupervisedEitherFork =
val result = supervised:
either:
fork:
Left("whoops").ok()
Thread.sleep(1000)
Right(42).ok()
println(s"result = $result")
Running this:
Exception in thread "main" scala.util.boundary$Break
The application crashes. Here’s what happens step by step:
supervisedcreates a concurrency scope on the main thread.eithercreates aboundarywith aLabel– the boundary’stry/catchlives on the main thread, insidesupervised’s body.forkspawns a daemon virtual thread that captureseither’sLabel.- The main thread enters
Thread.sleep(1000). - The daemon thread calls
.ok(), which throwsBreak(label, Left("whoops")). supervisedcatches the fork failure and interrupts the main thread.supervisedrethrows theBreak– but at this point, we’re unwindingsuperviseditself. The rethrow happens at thesupervised:call site, which is outsideeither’s boundary.- The
Breakpropagates uncaught and crashes the application.
The label escaped from either into a fork, crossed a thread boundary, and when the Break came back through supervised’s error handling, it landed above either’s try/catch. The boundary never got a chance to catch its own Break.
This is the most dangerous variant of the lateral escape problem. With Try, the break is silently swallowed. Here, it crashes the application. And the difference between the safe version (either { supervised { fork { .ok() } } }) and the crashing version (supervised { either { fork { .ok() } } }) is just the nesting order – something that’s easy to get wrong and produces no compiler warning.
The -> T approach that “fixes” Try would reject the crashing case, but it would also reject the perfectly safe either { supervised { forkUser { .ok() } } } case. If fork’s body parameter were pure, .ok() inside any fork would be rejected because it needs the Label capability from either. But in the correct nesting order with forkUser, the break will propagate correctly through structured concurrency – supervised rethrows inside either’s boundary, exactly where it should land.
What we actually need is for the type system to distinguish between these two nesting orders. fork’s signature needs to say something like: “this body can capture the Ox scope capability and the Label capability, but only if the Label’s boundary is outside the supervised scope so that rethrown exceptions land in the right place.” The current capture checking system can’t express this – there’s no way to reason about the relative nesting of exception handlers and concurrency scopes at the type level.
Previous work: counter-capabilities in Ox
This problem was explored in ox#146, where I attempted a workaround using inline metaprogramming and counter-capabilities - a technique sometimes called “given’t” (given removal through implicit ambiguity).
The idea was to introduce marker types Forked and Supervised, provided as implicit evidence by fork and supervised respectively. The .ok() combinator was made transparent inline and used summonFrom to check at compile time: if Forked is in scope but Supervised is not, that means we’re inside a fork that’s not wrapped in a supervised block within the fork itself, so the .ok() call is rejected with a compile error.
The either block used the “given’t” pattern to remove these markers from scope within its body, so that a fresh either inside a fork would reset the state and allow .ok() again. This allowed the safe case:
either:
supervised:
fork:
Right(1).ok() // allowed: fork propagates breaks through supervised
While rejecting the dangerous case:
supervised:
either:
fork:
Right(1).ok() // rejected: .ok() targets either outside the fork
However, this approach hit limitations. Nested scope combinations like supervised { either { fork { supervised { Right(1).ok() } } } } broke the checks because the inner supervised re-introduced the Supervised marker, making the compile-time condition pass even though the either label would still escape. The machinery got increasingly complex, and the PR was ultimately closed with the conclusion that it didn’t carry its weight - the workaround was too fragile for the edge cases it tried to cover, and the real solution lies in the type system itself.
Capture checking was designed to solve exactly this class of problems - capabilities escaping into contexts where they can’t be properly handled. The docs explicitly list boundary labels and checked exceptions as motivating use cases. The Label type extends caps.Control specifically to participate in capture checking.
But the current system seems to have a gap when it comes to lateral escape - where a capability doesn’t escape in the return type, but is captured by a body that executes in a different exception-handling or threading context. The tools available today are either too restrictive (-> T blocks all capabilities) or too permissive (=> T allows all capabilities including labels).
My question to Martin and the team working on Caprese is: what’s the correct way to solve these problems?
Is this an area of active research that I just haven’t heard about yet? Have I missed something obvious that’s already baked into the current design available in Scala 3.8?