lampepfl / gears

A strawman for a low-level async library in Scala 3.
https://lampepfl.github.io/gears/
Apache License 2.0
247 stars 24 forks source link

Tests for capture checking #67

Open odersky opened 5 months ago

odersky commented 5 months ago

When we get capture checked Gears it would be good that the following works as expected:

val good1: List[Future[Result[T]]] -> Future[Result[List[T]]] = frs =>
  Future:
    Result:
      frs.map(_.await.ok)

val good2: Result[Future[T]] -> Future[Result[T]] = rf =>
  Future:
    Result:
      rf.ok.await  // OK, Future argument has type Result[T]

On the other hand, the following case should fail:

val fail3: Future[Result[T]] -> Result[Future[T]] = fr =>
  Result: 
    Future:
      fr.await.ok // error, escaping label from Result

This fails because it is expanded to

val fail3: Future[Result[T]] -> Result[Future[T]] = fr =>
  Result: lbl ?=> 
    Future:
      fr.await.ok(using lbl) // error, escaping label from Result

and the Future block has type Future[T]^{async, lbl}. A transform having that type can be constructed but would be useless since we take a future of a value:

val useless4: Future[Result[T]] -> Result[Future[T]] = fr =>
  fr.await.map(Future(_))

The difference between good2 and fail3 is that Result is strict and Future is not.

odersky commented 4 months ago

The following minimization gives the correct error, even if the error message could certainly be imrpoved:

import scala.util.boundary, boundary.{Label, break}

class Async
class Future[+T]:
  this: Future[T]^ =>
  def await(using Async^): T = ???
object Future:
  def apply[T](op: Async^ ?=> T)(using Async): Future[T]^{op} = ???

abstract class Result[+T, +E]
case class Ok[+T](value: T)  extends Result[T, Nothing]
case class Err[+E](value: E) extends Result[Nothing, E]

object Result:
  extension [T, E](r: Result[T, E])

    /** `_.ok` propagates Err to current Label */
    def ok(using Label[Err[E]]^): T = r match
      case r: Ok[_] => r.value
      case err => break(err.asInstanceOf[Err[E]])

  def apply[T, E](body: Label[Err[E]]^ ?=> T): Result[T, E] =
    boundary:
      val result = body
      Ok(result)

end Result

def test[T, E](using Async) =
  val good1: List[Future[Result[T, E]]] => Future[Result[List[T], E]] = frs =>
    Future:
      Result:
        frs.map(_.await.ok)

  val good2: Result[Future[T], E] => Future[Result[T, E]] = rf =>
    Future:
      Result:
        rf.ok.await  // OK, Future argument has type Result[T]

  def fail3(fr: Future[Result[T, E]]^) =
    Result:
      Future:
        fr.await.ok // error, escaping label from Result

We get:

-- Error: effect-swaps.scala:41:4 ----------------------------------------------
41 |    Result:
   |    ^^^^^^
   |local reference contextual$6 leaks into outer capture set of type parameter T of method apply
1 error found

But it does give a weirder error message if the apply method in Result is declared inline with inline arguments:

  inline def apply[T, E](inline body: Label[Err[E]]^ ?=> T): Result[T, E] =
    boundary:
      val result = body
      Ok(result)

In this case we get:

-- [E007] Type Mismatch Error: effect-swaps.scala:42:6 -------------------------
41 |    Result:
42 |      Future:
   |    ^
   |    Found:    Result[box Future[box T^?]^{fr}, box E^?]^?
   |    Required: Result[Future[T], E]
43 |        fr.await.ok // error, escaping label from Result
   |----------------------------------------------------------------------------
   |Inline stack trace
   |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
   |This location contains code that was inlined from effect-swaps.scala:24
23 |    boundary:
24 |      val result = body
   |    ^
25 |      Ok(result)
    ----------------------------------------------------------------------------
   |
   | longer explanation available when compiling with `-explain`
1 error found

And it compiles without errors if apply is declared as transparent inline.

odersky commented 4 months ago

I think the false positives came because boundary itself was not capture checked. I now added it to the test as follows:

import annotation.capability

object boundary:

  @capability final class Label[-T]

  /** Abort current computation and instead return `value` as the value of
   *  the enclosing `boundary` call that created `label`.
   */
  def break[T](value: T)(using label: Label[T]): Nothing = ???

  def apply[T](body: Label[T] ?=> T): T = ???
end boundary

import boundary.{Label, break}

class Async
class Future[+T]:
  this: Future[T]^ =>
  def await(using Async^): T = ???
object Future:
  def apply[T](op: Async^ ?=> T)(using Async): Future[T]^{op} = ???

abstract class Result[+T, +E]
case class Ok[+T](value: T)  extends Result[T, Nothing]
case class Err[+E](value: E) extends Result[Nothing, E]

object Result:
  extension [T, E](r: Result[T, E])

    /** `_.ok` propagates Err to current Label */
    def ok(using Label[Err[E]]): T = r match
      case r: Ok[_] => r.value
      case err => break(err.asInstanceOf[Err[E]])

  transparent inline def apply[T, E](inline body: Label[Err[E]] ?=> T): Result[T, E] =
    boundary:
      val result = body
      Ok(result)

end Result

def test[T, E](using Async) =
  val good1: List[Future[Result[T, E]]] => Future[Result[List[T], E]] = frs =>
    Future:
      Result:
        frs.map(_.await.ok)

  val good2: Result[Future[T], E] => Future[Result[T, E]] = rf =>
    Future:
      Result:
        rf.ok.await  // OK, Future argument has type Result[T]

  def fail3(fr: Future[Result[T, E]]^) =
    Result:
      Future:
        fr.await.ok // error, escaping label from Result

And that gives the expected error:

-- Error: effect-swaps.scala:56:6 ----------------------------------------------
55 |    Result:
56 |      Future:
   |    ^
   |local reference contextual$1 leaks into outer capture set of type parameter T of method apply
57 |        fr.await.ok // error, escaping label from Result
   |----------------------------------------------------------------------------
   |Inline stack trace
   |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
   |This location contains code that was inlined from effect-swaps.scala:37
37 |    boundary:
   |    ^^^^^^^^
    ----------------------------------------------------------------------------
1 error found

I am still not sure why the experiment with actual Gears code inferred Result[Any, Any].

natsukagami commented 4 months ago

I wrote down the relevant parts here. This is code not using any Gears implementation, and should still compile

import language.experimental.captureChecking

import scala.annotation.capability

@capability trait Async

object Async:
  def blocking[T](body: Async ?=> T): T = ???

object boundary:
  @capability final class Label[-T]

  /** Abort current computation and instead return `value` as the value of
   *  the enclosing `boundary` call that created `label`.
   */
  def break[T](value: T)(using label: Label[T]): Nothing = ???

  def apply[T](body: Label[T] ?=> T): T = ???
end boundary

enum Result[+T, +E]:
  case Ok[+T](value: T) extends Result[T, Nothing]
  case Err[+E](error: E) extends Result[Nothing, E]

object Result:
  import boundary.Label

  def apply[T, E](body: Label[Result[T, E]] ?=> T): Result[T, E] =
    boundary(Ok(body))

  extension [U, E](r: Result[U, E]^)(using Label[Err[E]])
    def ok: U = r match
      case Err(value)  => boundary.break[Err[E]](Err(value))
      case Ok(value) => value

class Future[+T]():
  def await(using Async): T = ???
  // def awaitResult(using Async): scala.util.Try[T] = ???
object Future:
  def apply[T](body: Async ?=> T)(using spawn: Async): Future[T]^{body} = ???

def main() =
  import Result.*
  Async.blocking: async ?=>
    def fail3[T, E](fr: Future[Result[T, E]]^) =
      Result: label ?=>
        val f: Future[T]^{async, fr, label} = Future: fut ?=>
          fr.await.ok(using label) // error, escaping label from Result
        f

@odersky

natsukagami commented 4 months ago

It seems that changing

object Result:
  def apply[T, E](body: Label[Result[T, E]] ?=> T): Result[T, E] = 

to

object Result:
  def apply[T, E](body: Label[Err[E]] ?=> T): Result[T, E] = 

will make the compiler emit the error you had.

We do need to have a Label[Result[T, E]] however, because we do want the user to emit a boundary.break with a Result (for tail calls).

odersky commented 4 months ago

We do need to have a Label[Result[T, E]] however, because we do want the user to emit a boundary.break with a Result (for tail calls).

In #20244 I have a minimization along these lines. But I don't get the error you see, for me it's still a "local reference leaks into outer capture set" message.

natsukagami commented 4 months ago

Does compiling the minimization at https://github.com/lampepfl/gears/issues/67#issuecomment-2067802532 give you the "local reference leaks" error?

I'm getting no compiler errors using sbt scalac Test.scala from scala/scala3#20241 branch.

odersky commented 4 months ago

That minimization also compiles for me without errors and a Result[Any, Any] label. I chased it down to the following difference: If we write fail3 like this, it compiles:

   def fail3[T, E](fr: Future[Result[T, E]]^) =
      Result: label ?=>
        val f = Future: fut ?=>
          fr.await.ok // error, escaping label from Result
        f
 Result: label ?=>

But if we drop the label ?=> and use an implicit context funciton parameter instead, we get the leak error:

    def fail3[T, E](fr: Future[Result[T, E]]^) =
      Result:
        val f = Future: fut ?=>
          fr.await.ok // error, escaping label from Result
        f

-- Error: effect-swaps-2.scala:46:6 --------------------------------------------
46 |      Result: // contextual$2 ?=>
   |      ^^^^^^
   |local reference contextual$2 from (using contextual$2:
   |  boundary.Label[
   |    Result[box Future[box T^?]^{fr, contextual$2, contextual$2}, box E^?]]^
   |): box Future[box T^?]^{fr, contextual$2, contextual$2} leaks into outer capture set of type parameter T of method apply in object Result
1 error found

I

odersky commented 4 months ago

And it turns out that difference is already present after typer. With explicit label, it infers Result[Any, Any] for the result type of fail3, whereas without implicit label it infers Result[Future[T], E].