Capture checking and lateral escape of Labels

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 alongside CanThrow (checked exceptions) and Async (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:

  1. supervised creates a concurrency scope on the main thread.
  2. either creates a boundary with a Label – the boundary’s try/catch lives on the main thread, inside supervised’s body.
  3. fork spawns a daemon virtual thread that captures either’s Label.
  4. The main thread enters Thread.sleep(1000).
  5. The daemon thread calls .ok(), which throws Break(label, Left("whoops")).
  6. supervised catches the fork failure and interrupts the main thread.
  7. supervised rethrows the Break – but at this point, we’re unwinding supervised itself. The rethrow happens at the supervised: call site, which is outside either’s boundary.
  8. The Break propagates 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?

4 Likes

Back when the issue came up on the Ox mailing list, I ended up with my own solution that relies on cooperative definition of scope, which I called corrals. You can only hop within your own corral, which is enforced by using path-dependent types.

Implementation is here and an example in the tests is here

And here’s a shell example showing that only in-corral hops work:

scala> Corral:
         hop[Int].here:
           Corral:
             hop[String].here:
               Hop.jump("eel")
               "bass"
           .length
       
val res3: Int = 3
                                                                                
scala> Corral:
         hop[Int].here:
           Corral:
             hop[String].here:
               Hop.jump(3)
               "bass"
           .length
       
-- Error: ----------------------------------------------------------------------
5 |        Hop.jump(3)
  |        ^^^^^^^^^^^
  |        Hop cannot cross its containing Corral
1 error found

While I agree that capture checking ought to be able to handle this kind of thing, it’s also the case that using an opt-in cooperative mechanism, it already works, and robustly (albeit at a pretty heavy type-signature cost).

The problem?

The optimizer no longer optimizes the jumps reliably. Which, for my purposes, makes it useless. Therefore, I don’t make my thread boundaries Corrals and implement my jumps with Hop.

So, instead, I’m in the same boat as Ox. If capture checking could help, that would be great. As a workaround, I make every boundary an either-error-type boundary (I have my own, which I call Ask, which is an alias to A Or Err, where Or is a success-unboxed left-favored sum type, and Err is an error-wrapping abstraction.) So my equivalent would be written as

Fu.group:
  Ask:
    Fu:
      Alt(Err("This went wrong")).?
      7
    .ask().?
  .?

And at every point, the .? would fail up to the previous level–but since it’s all inline, that’s fine: each time is a quick check and a simple jump (and the JIT compiler may be able to optimize away the repeated checks of the same thing).

But this is not great. It’s trivial to break with break or even .? by simply targeting something that is of a different type (.? is defined for the disfavored branch of common sum types regardless of the type of the disfavored branch). So it helps avoid correctness problems, but it is a far cry from guaranteeing correctness in this regard.

Anyway, if capture checking can help create a solution, that would be great! In the meantime, my policy of (1) Use Ask, and (2) never use Try (or NonFatal) works well in practice. I actually don’t end up with many mistakes–it’s not a serious pain point.

But that’s because I avoided the Ox-style nesting and compromised a little on syntax. (There is still the problem of capturing jump-capable things in closures–that is a capture checker-type issue. This also hasn’t been much of a problem for me in practice, though. It’s a mistake waiting to happen, but not the type of mistake I’m particularly prone to making, fortunately.)

You want to try Capability Classifiers Capability Classifiers

I thought I did try classifiers @bishabosha. They don’t seem to be able to express what is needed here. Control extends SharedCapability explicitly and it seems that the problem is that mechanisms that use exceptions to travel on the call stack are not really shareable or, to be more precise, shareable with other exception catching contexts (that includes both things like Try, other try/catch blocks but also threads).

In fact, let me theorize a bit:

  1. if Label <:< Control <:< SharedCapability it means it can be aliased multiple times (good) but also it can be shared between threads (bad)
  2. if Label <:< ExclusiveCapability it means it can’t leave to another thread but also that it can’t be aliased multiple times which breaks a lot of reasonable use cases with more than one break point
  3. therefore it means that the current hierarchy has no way to express something that should not be shared with other threads but also that can be safely aliased multiple times (on the same thread).

The hierarchy has two axes (shared/exclusive) but the problem needs a third distinction. Shared means “aliasable AND can cross thread boundaries” and exclusive means “can’t cross threads BUT also can’t be aliased.” Labels seem to need “aliasable but thread-local” and there’s no slot for that in the current tree.

We are aware of the Try / boundary situation. The Control capabilities capture for Try[T] constructor (which is sitting in Capture check `Try` by natsukagami · Pull Request #25072 · scala/scala3 · GitHub ) allows you to capture the break labels, as long as you don’t let the Trys go out of scope as well. This is definitely a decision, that gives you a bit more expressiveness, but the behavior could be surprising. Personally I am also not firmly set on this decision, and having a negative bound is useful (and there’s an argument there that it should be the default).

And speaking of negative bounds themselves, support for classifier exclusion .except[Control] is on the roadmap for classifiers! There is ongoing research work in progress; as usual, negative bounds are tricky to get right.

2 Likes

Awesome to hear that! I don’t have anything against Try being able to capture Control caps, it’s just that Break being one makes it possible for a lot of very surprising behaviours that make refactors a lot more fragile. Consider this example:

extension (t: Try.type) 
  def safe[T](body: => T): Try[T]^{body.only[Control]} = Try(body)

boundary:
  val t = Try.safe { break(42); 0 } // t^{local}
  t.getOrElse(-1)                   // Int!

Your PR for Try CC doesn’t catch this - getOrElse is still able to silently swallow the break.

Let me show you this in practice with a realistic-looking piece of code:

//> using scala 3.8.2
import language.experimental.captureChecking

import scala.util.boundary, boundary.break
import scala.util.{Try, Success, Failure}
import caps.Control

extension (t: Try.type)
  def safe[T](body: => T): Try[T]^{body.only[Control]} = Try(body)

case class Trade(accountId: Int, symbol: String, quantity: Int)
case class Summary(total: BigDecimal)

val exchangeRates = Map("AAPL" -> BigDecimal(195), "GOOG" -> BigDecimal(175))

object cache:
  // stale but usable prices for when the db is temporarily unreachable
  // DELISTED is a delisted stock still lingering in cache
  val lastKnownPrices = Map("AAPL" -> BigDecimal(190), "GOOG" -> BigDecimal(170), "DELISTED" -> BigDecimal(50))
  def lastKnownPrice(symbol: String): BigDecimal =
    lastKnownPrices.getOrElse(symbol, BigDecimal(0))

object tx:
  private val debits = collection.mutable.ListBuffer[(Int, BigDecimal)]()

  def transact[T](body: => T): T =
    debits.clear()
    try
      val result = body
      println(s"  TX COMMITTED: ${debits.mkString(", ")}")
      result
    catch
      case e: Exception =>
        println(s"  TX ROLLED BACK: ${e.getClass.getSimpleName}")
        throw e

  def lookup(trade: Trade): Trade =
    if trade.accountId == 999 then throw RuntimeException("connection timeout")
    trade

  def debit(accountId: Int, amount: BigDecimal): Unit =
    debits += ((accountId, amount))

// === Version 1: resolvePrice is pure, everything works ===
def updatePortfolioV1(trades: List[Trade]): Either[String, Summary] = boundary:
  val summary = tx.transact:
    def resolvePrice(symbol: String): BigDecimal =
      exchangeRates(symbol)

    var total = BigDecimal(0)
    for trade <- trades do
      val price = Try.safe:
        val enriched = tx.lookup(trade)
        resolvePrice(enriched.symbol)
      .getOrElse(cache.lastKnownPrice(trade.symbol)) // db hiccup -> use stale price

      tx.debit(trade.accountId, price * trade.quantity)
      total += price * trade.quantity

    Summary(total)

  Right(summary)

// === Version 2: resolvePrice now validates, break should abort+rollback ===
def updatePortfolioV2(trades: List[Trade]): Either[String, Summary] = boundary:
  val summary = tx.transact:
    def resolvePrice(symbol: String): BigDecimal =
      if !exchangeRates.contains(symbol) then
        break(Left(s"corrupt trade: unknown symbol '$symbol', aborting transaction"))
      exchangeRates(symbol)

    var total = BigDecimal(0)
    for trade <- trades do
      val price = Try.safe:
        val enriched = tx.lookup(trade)
        resolvePrice(enriched.symbol)
      .getOrElse(cache.lastKnownPrice(trade.symbol))

      tx.debit(trade.accountId, price * trade.quantity)
      total += price * trade.quantity

    Summary(total)

  Right(summary)

@main def refactorExample =
  val goodTrades = List(Trade(1, "AAPL", 10), Trade(2, "GOOG", 5))
  val corruptTrades = List(Trade(1, "AAPL", 10), Trade(2, "DELISTED", 5), Trade(3, "GOOG", 20))
  val dbErrorTrades = List(Trade(1, "AAPL", 10), Trade(999, "GOOG", 5))

  println("=== V1: resolvePrice is pure ===")
  println(s"V1 happy:   ${updatePortfolioV1(goodTrades)}")
  println(s"V1 db err:  ${updatePortfolioV1(dbErrorTrades)}")
  println(s"V1 corrupt: ${updatePortfolioV1(corruptTrades)}")

  println()
  println("=== V2: resolvePrice validates, break should rollback ===")
  println(s"V2 happy:   ${updatePortfolioV2(goodTrades)}")
  println(s"V2 db err:  ${updatePortfolioV2(dbErrorTrades)}")
  println(s"V2 corrupt: ${updatePortfolioV2(corruptTrades)}")
  // ^ SHOULD return Left("corrupt trade: empty symbol, aborting transaction")
  //   with TX ROLLED BACK.
  //   Instead: Try swallows the Break, corrupt trade executes at stale cache
  //   price, transaction COMMITS with wrong data.

In this example the problem before and after the refactor shows clearly a situation where this becomes a source of subtle bugs. In V1, resolvePrice is a pure function – just a map lookup. Try wraps the database call that might throw on connection errors, and getOrElse falls back to a stale cached price, a reasonable degradation for transient failures. In V2, a validation check is added: if the symbol isn’t on the current exchange (e.g. a delisted stock), break to abort the transaction and roll back all debits. The Label is in scope, the code compiles, the intent is clear. But resolvePrice is called inside the Try block. The Break is caught by Try before it can reach transact (for rollback) or boundary (for the Left return). getOrElse treats it as a connection error and falls back to the stale cached price. The transaction commits, debiting accounts based on a delisted stock’s stale price while the developer’s safety check was silently converted into a cache lookup.

I do understand the logic behind “Try should be able to capture Label and move it around in the legal scope” but for that to work correctly, the Try’s getOrElse ought not to compile if the Try captured a Label. Same applies to any transform or a patmat that does not rethrow the Label.

The same logic could apply to fork, Future or anything else that might break the structural logic of boundary/break - if there was a way to force a .join if Label was captured, it would be sound.

It does sound like a massive complication over just disallowing Try, Future or fork from capturing Label ever though.