Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Go back towards a pure capability system #21764

Open
wants to merge 15 commits into
base: main
Choose a base branch
from

Conversation

odersky
Copy link
Contributor

@odersky odersky commented Oct 14, 2024

  • Drop @unbox annotation, don't replace with @use.
  • Charge deep capture set of arguments to cv.
  • Multiple fixes to make sure deep capture set computations and reach capability mappings are in sync.
  • Some fixes for handling paths.

The aim of this PR is to go to a stable system for handling reach capabilities in a classical way before embarking on a more effect-system like treatment where we distinguish use later from use now.

noti0na1 and others added 13 commits September 25, 2024 21:45
This is done for comparing old with new
Add the path cases without changing the whole logic
If we refer to a path `a.b`, we should mark `a.b` as used,
which is better than marking `a`.
Needed to make stdlib2-cc go through.

There were two errors. One in LayListIterable required a type annotation
and a tweak to markFree. The other in Vieew.scala required a cast, but this could be fixed
with better handling of pattern matching. path-patmat-should-be-pos.scala is a minimization.
x*? is x.type @reach @maybe. This was not recognized before.
Previously, we violated that assumption is we too the deep capture set
of a capture reference wiht singleton type.
Count in dcs exactly those locations where a cap gets replaced by a reach capability.
Also: In Recheck-apply, use deep capture sets of arguments in computing the
result alternative.

Drop restrictions on leaking reach capabilities in markFree.

Revise visibility criterion for paths.

Together these changes now implement a classical capability system with reach capabilities.
References that are used later after passing some arguments are already recorded in capture sets
of earlier stages (exception: closure results).
@Linyxus Linyxus self-requested a review October 14, 2024 13:51
@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

Soundness issue found:

  import language.experimental.captureChecking

  // ok
  def runOps(ops: List[() => Unit]): Unit =
    ops.foreach(op => op())

  // ok
  def delayedRunOps(ops: List[() => Unit]): () ->{ops*} Unit =
    () => runOps(ops)

  // unsound: impure operation pretended pure
  def delayedRunOps1(ops: List[() => Unit]): () ->{} Unit =
    () =>
      val ops1 = ops
      runOps(ops1)

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

Another soundness issue found:

import language.experimental.captureChecking

def runOps(ops: List[() => Unit]): Unit =
  ops.foreach(op => op())

def app[T, U](x: T, op: T => U): () ->{op} U =
  () => op(x)

def unsafeRunOps(ops: List[() => Unit]): () ->{} Unit =
  app[List[() ->{ops*} Unit], Unit](ops, runOps)

@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

Good points. The first soundness issue is in the deep capture set computation. We think the deep capture set of ops1 is ops1* since ops1 is a capability. But that's true only if all capabilities in the underlying set are shorter-lived than ops1. In this case, that's not the case, sothe deep capture set of ops1 should be ops*, not ops1*.

@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

The second unsoundness example has the same root issue.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

How is the second one sharing the root cause with the first? My idea behind that example was that given (x:T) where T is a type parameter with no upper bound we can show {x*} <: {}. Then this can be broken by instantiating T.

A deep capture set should not be shortened to a reach capability `x*`
if there are elements in the underlying set that live longer than `x`.
@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

@Linyxus Both examples are fixed by the same change to dcs computation. For the second example we get now:

-- [E007] Type Mismatch Error: delayedRunops2.scala:10:35 ----------------------
10 |  app[List[() ->{ops*} Unit], Unit](ops, runOps) // error
   |  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   |  Found:    () ->{ops*} Unit
   |  Required: () -> Unit
   |
   | longer explanation available when compiling with `-explain

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

I see that in reachCanSubsumeDcs a special case is made for cap, but cap might well represent something outlives the reach. For instance, the following compiles again:

import language.experimental.captureChecking

def runOps(ops1: List[() => Unit]): Unit =
  ops1.foreach(op => op())

def app[T, U](x: T, op: T => U): () ->{op} U =
  () => op(x)

def unsafeRunOps(ops: List[() => Unit]): () ->{} Unit =
  app[List[() => Unit], Unit](ops, runOps)

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

Also, the error in the second example depends on the fact that runOps is eta-expanded when passed to app on the last line. See the tree:

    def unsafeRunOps(ops: List[box () => Unit]): () -> Unit =
      app[List[box () ->{ops*} Unit], Unit](ops,
        {
          def $anonfun(ops1: List[box () ->{ops*} Unit]^?): Unit = runOps(ops1)
          closure($anonfun)
        }
      )

When marking-free ops1 inside $anonfun, its dcs contains ops* now.

But this does not really fix the problem. For instance, this can be by passed by wrapping runOps inside a class, so that no eta-expansion happens:

import language.experimental.captureChecking

def runOps(ops: List[() => Unit]): Unit =
  ops.foreach(op => op())

trait Consumer[-T]:
  def take(x: T): Unit

class OpsRunner extends Consumer[List[() => Unit]]:
  def take(x: List[() => Unit]) = runOps(x)

def app[T](x: T, op: Consumer[T]^): () ->{op} Unit =
  () => op.take(x)

def unsafeRunOps(ops: List[() => Unit]): () ->{} Unit =
  val x: Consumer[List[() ->{ops*} Unit]]^{} = new OpsRunner
  app[List[() ->{ops*} Unit]](ops, x)

The semantics stays the same, yet we bypass the capture checker.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

IMO, the root cause of the second issue is in:

def app[T, U](x: T, op: T => U): () ->{op} U =
  () => op(x)

The closure returned from this function now only captures op, yet it should capture both op and x*. When typing the body of the app function, we assumed that {x*} <: {}, since x's type is T whose upper bound is Any (and therefore elide x* from the capture set). In other words, we have {x*} <: dcs(T) and at this point we believed dcs(T) is empty. But, this contract is broken after instantiating T to, say, List[() ->{ops*} Unit].

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

I see that in reachCanSubsumeDcs a special case is made for cap, but cap might well represent something outlives the reach. For instance, the following compiles again:

import language.experimental.captureChecking

def runOps(ops1: List[() => Unit]): Unit =
  ops1.foreach(op => op())

def app[T, U](x: T, op: T => U): () ->{op} U =
  () => op(x)

def unsafeRunOps(ops: List[() => Unit]): () ->{} Unit =
  app[List[() => Unit], Unit](ops, runOps)

In a similar manner, the first example bypasses the capture checker if we do:

import language.experimental.captureChecking

// ok
def runOps(ops: List[() => Unit]): Unit =
  ops.foreach(op => op())

// ok
def delayedRunOps(ops: List[() => Unit]): () ->{ops*} Unit =
  () => runOps(ops)

// unsound: impure operation pretended pure
def delayedRunOps1(ops: List[() => Unit]): () ->{} Unit =
  () =>
    val ops1: List[() => Unit] = ops
    runOps(ops1)

The change is on the second but last line: we added a type ascription to the val binding.

@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

he closure returned from this function now only captures op, yet it should capture both op and x*. When typing the body of the app function, we assumed that {x*} <: {}, since x's type is T whose upper bound is Any (and therefore elide x* from the capture set). In other words, we have {x*} <: dcs(T) and at this point we believed dcs(T) is empty. But, this contract is broken after instantiating T to, say, List[() ->{ops*} Unit].

Yes, but this will make a total mess of parametric reasoning. I fear this would be far too restrictive if we have to assume cap for every time variable reference. We need something better. But what?

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

I think the kernel of the first issue:

def delayedRunOps1(ops: List[() => Unit]): () ->{} Unit =
  () =>
    val ops1: List[() ->{ops*} Unit] = ops
    runOps(ops1)

is that the reach capabilities term parameters and local definitions should be treated differently. What happened when we type runOps(ops1) is that {ops1} is charged to the environment, then gets dropped since it is local. If it was a reach capability of a parameter reference, e.g. ops*, dropping it when it goes out of the defining scope is fine: we get compensated when applying the function, where the dcs of the argument will be charged.

But it is different for local definitions. There is no compensation for them. When a reach capability of them, like ops1*, goes out of scope. We have to do something to approximate. Throwing it away is unsound.

One possibility is to widen it, similar to what is already implemented: we see that the type of ops1 is List[() ->{ops*} Unit], so we widen {ops1*} to {ops*}.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

And when the dcs of the type of ops1 contains cap, we should error, like in the revised snippet:

def delayedRunOps1(ops: List[() => Unit]): () ->{} Unit =
  () =>
    val ops1: List[() => Unit] = ops
    runOps(ops1)

We can understand it from a Capless perspective. List[() => Unit] is Exists C. List[() ->{C^} Unit]:

def delayedRunOps1(ops: List[() => Unit]): () ->{} Unit =
  () =>
    val (ops1*, ops1) = (ops : Exists C. List[() ->{C^} Unit])
      // this is an existential unpack; ops1* is the capture variable, and ops1 the term variable.
    runOps(ops1)

Then there is no way to approximate ops1*: we've already lose track. It represents "some capabilities" that exists. But we get no idea what they are.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

If we instead adopt the above mechanism in place of reachCanSubsumeDcs, it could also save us from the nightmare of parametric reasoning, since very often given x: X where X is a type parameter, x is a term parameter. This means that in most cases there is no need to widen x* (we either drop it or just keep it). Although we consider dcs(X) = {cap} which is imprecise and even invalid to widen into, we will just refrain from widening x* to dcs(X).

@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

I see how this would fix the delayedRunops1 example. But what about the example with app? That looks like the harder problem to me.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

For app, since now we assumed dcs(T) can be cap, it will not typecheck. It only typechecks if we do:

def app[T, U](x: T, op: T => U): () ->{x*,op} U =
  () => op(x)

and then unsafeRunOps will not typecheck either.

@odersky
Copy link
Contributor Author

odersky commented Oct 14, 2024

Not sure. dcs[T] being cap looks very problematic. I was hoping we could avoid that, but it seems not.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 14, 2024

It could be possible to avoid that, if we either:

  • charge dcs(T) for any x[T], so that it is compensated;
  • or restrict the instances of type parameters to have an empty dcs, so that the contrat is not broken

@odersky
Copy link
Contributor Author

odersky commented Oct 15, 2024

Here's a modified version of app which combines both of our difficulties: dcs of type variables and local definitions:

def app2[T, U](x: T, op: T => U): () ->{op} U =
  () => 
     def y: T = x
     op(y)

We could solve this by allowing regular type variables in capture sets. E.g. a sound version of app2 could be:

def app2[T, U](x: T, op: T => U): () ->{T^, op} U =
  () => 
     def y: T = x
     op(y)

Looks a bit like like a version of cc that we had early on.

@odersky
Copy link
Contributor Author

odersky commented Oct 15, 2024

The idea is that dcs(T) = T^ instead of widening to underlying dcs of T's bound.

@odersky
Copy link
Contributor Author

odersky commented Oct 15, 2024

When substituting U for T, we replace every occurrence of T^ in a capture set by dcs(U). This includes capture set variables as a special case since dcs(CapSet^{a, b, c}) is {a, b, c}.

@odersky
Copy link
Contributor Author

odersky commented Oct 15, 2024

Another simplification could be to admit x* only if x is a parameter. That would avoid the awkwardness of having to avoid capture sets of locally defined vals.

@Linyxus
Copy link
Contributor

Linyxus commented Oct 15, 2024

Given that in most cases, we write the following form:

def f[T](x: T): ...

where type parameters are followed by term ones and become the type of the term parameters, would it be possible to simply use x* in the places that T^ are used? So there is no need to introduce T^, which is the exact problem boxes are trying to avoid.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants