State of Panama Scopes

State of Panama Scopes

January 2019: (v. 0.1)

Maurizio Cimadamore

Scopes represents a cornerstone of the Panama foreign API, which is used to enforce dynamic liveness checks on scope resources (such as pointers), thus providing enhanced safety guarantees when manipulating native resources in Java. As an example of Panama code using scopes, consider the following snippet:

Pointer<Integer> pi;
try (Scope scope = Scope.newNativeScope()) {
   pi = scope.allocate(NativeTypes.INT32);
   pi.set(42);
   ...
} //scope implicitly closed here
int i = pi.get(); //exception: scope not alive!

This snippet highlights several features of the Scope API; first, scopes can be used in conjunction with the try-with-resources construct, which makes it easy to create scopes that are meant to be stack-confined. At the end of the try block, the scope is automatically closed, so that all associated native resources are automatically collected. We can also see how Scope provides methods to allocate memory regions and obtain pointers to such regions. The pointers produced by such allocation methods will keep track of the owning scope - this allows for increased safety around pointer dereference operations: any attempt to dereference a pointer whose scope has been closed will fail with an exception.

While the Scope API is generally usable and useful (thanks to the dynamic liveness check) there have been questions over the completeness of such an API (summarized here and here); for instance, can the scope of a resource (e.g. a pointer) be safely mutated after the fact (also known as resource handoff) ? And, furthermore, is the set of dynamic checks provided by the Scope API enough to prevent mis-usages of resources associated with a given scope?

Writing pointers

Perhaps the biggest problem associated with manipulating native pointers has to do with when such pointers are serialized onto native memory. When this happens, all contextual information about a pointer (its scope, its boundaries) is lost - since the serialized form of a pointer is just an address (the size of the address depends on the platform considered). Consider the following code:

Pointer<Pointer<Integer>> target =
        sourceScope.allocate(NativeTypes.INT32.pointer());
Pointer<Integer> source =
        targetScope.allocate(NativeTypes.INT32);
target.set(source);
...
int i = target.get().get(); //???

There is a subtle issue with the above code: the code is attempting to write a pointer with given scope (sourceScope), onto a memory region held by a different scope (targetScope). Is this operation safe?

The answer is, it depends. Now, if we could serialize the scope information along with the pointer address in the target memory region, there would be no issue: when the innermost pointer is deserialized, the correct scope information would be reconstructed, so as to prevent any bad pointer dereference. But serializing scope information together with pointer address comes at a very high cost, as we should reserve some space in the memory region where we keep track of scope information. This solution also seems very brittle: what happens if another process comes along and 'scribbles' over the reserved portion of the memory region where scope information has been stored?

Some other, simpler, solution is required.

Ownership model

A crucial observation is that the above snippet is safe if and only if the lifecycle of targetScope is shorter than the one of sourceScope. That is, if we can prove that targetScope will always be closed ahead of sourceScope, then serializing the pointer onto the target memory region is safe, as under no circumstances it would be possible to access the target memory region when the innermost pointer is no longer valid.

This suggests that a simple ownership model, where scopes have a single parent should be possible (a la RTSJ). For simplicity, let's assume that our model has a single root, a global scope, and that all other scopes are derived (or forked) from it. While a scope has only a single parent, it is important to note that a scope can have multiple descendants (as the same parent can be forked multiple times).

For consistency, we have to enforce that when a parent scope is closed, all its descendant scopes are also closed. For this, it follows that the lifecycle of descendant scopes is always shorter than that of their parents.

The key feature of this basic ownership model is to allow scopes to be compared simply and effectively. For instance, when writing a pointer onto a different memory region, as in the example above, we can establish some basic facts about the safety of the write operation just by looking at the ownership chains associated with the source and target scopes. More specifically, a write operation is safe if and only if the target scope is a descendant of the source scope.

Scope inference

As we have mentioned, once pointers are serialized onto native memory, all contextual information associated with such pointers is lost. This means that, in general, is not possible to recover the owning scope of a given pointer when deserializing (e.g. read) such pointer from native memory. This brings up a question: what should the scope of this newly minted scope be? This is what we mean by scope inference: finding a suitable scope owner for a given pointer resource whose original scope information has been lost. There are many possible ways for inferring the owning scope of a pointer; it is however important to note that not all possible choices for the inference scheme will lead to sound results. Consider for instance, an approach which always infers the owning scope of a deserialized pointer as the global scope. Such an approach is unavoidably unsafe, as there is no guarantee that the deserialized pointer will be meaningful for the entire duration of the application.

An approach which is both simple and effective is to infer the owning scope of a deserialized pointer from the very memory region from which the pointer is deserialized from. In other words, in the above case, if we read the innermost pointer, we would obtain a pointer instance whose owning scope is the same as the owning scope of the outermost pointer (inferred scope = targetScope). Note that this is always safe, as the lifecycle of the inferred scope is guaranteed to have a shorter span than the one of the original scope (sourceScope in the example). In other word, since the ownership model only allows us to write pointers in region which have a smaller lifecycle than the pointers themselves, inferring the pointer lifecycle as the lifecycle of such regions upon deserialization is always safe and guaranteed not to exceed the lifecycle of the original pointer.

Changing parents

In the past it has been suggested several times that the Scope API should support some way to handoff resources from one scope to another. On a closer look, this appears to be problematic, for a number of reasons. First, resources such as pointers are designed to be immutable cursors which could, one day, be replaced by value classes. Therefore it is not easy in general to tweak resources to have some mutable state (which would allow the owning scope to be tweaked). Moreover, it is sometimes possible for two or more resources to share the same underlying native memory region; this means that allowing a per-resource free or handoff operation can be dangerous, as these operations can sometimes side-effect resources that the programmer did not think about.

Given that it doesn't seem to make sense to think about operations such as handoff on a per-resource basis, what can be said about a scope-wide handoff equivalent? That is, is it possible to safely reassign the parent of a given scope to some other scope? In general the answer is, again, no - as there's no guarantee that the lifecycle of the new scope is going to be big enough to host all the resources available in the old scope.

That said, if we can prove that the new scope is an ancestor of the current scope, then we could have such a guarantee. But, in the general case, this would be problematic, as shown in the example below:

Scope grandparent = Scope.globalScope().fork();
Scope parent = grandparent.fork();
Scope child = parent.fork();
Pointer<Integer> parentPointer =
        parent.allocate(NativeTypes.INT32);
Pointer<Pointer<Integer>> childPointer =
        child.allocate(NativeTypes.INT32.pointer());
...
childPointer.set(parentPointer);
...
child.merge(grandparent);
...
parent.close();
childPointer.get(); // ???

Here we have three scopes, grandparent, parent and child, respectively. The child scope owns a pointer which is then used to store a pointer which belongs to the parent scope. According to the ownership rules illustrated above, this operation is legal, as the lifecycle of child is smaller than that of parent. After some activity, the child scope wants to merge with grandparent - this means that childPointer is now owned by the grandparent scope. But this is a recipe for error: we can now in fact close the parent scope, and still be able to dereference the childPointer resource, which is, of course, problematic (as the memory region childPointer points to will have been freed).

To conclude, the only safe handoff operation the Scope API can support consists in moving resources from a child scope to the parent scope. Moving resources further up the chain is, as the example demonstrates, not safe.

Scope lifecycle

As we have seen, each scope has a parent scope. For simplicity, we assume there must be a global scope such that each scope has the global scope as its ancestor. Moreover, we assume that every bound library will also feature its own scope, which can be used, for instance, for library specific allocation (e.g. allocation of library constants).

A scope is always created by forking a new scope off an existing scope; that is, if S2 is forked off S1, then S2's parent will be set to S1. After a Scope object has been created, it is ready for one or more allocations (using one of the allocate methods). The allocation activity must be followed by one terminal operation, which permanently changes the state of the Scope so that allocation is no longer available.

There are, as we have seen, two kinds of terminal operations supported by Scope in the design put forward in this document. The most common case is the close operation, which causes all native resources allocated with the scope to be reclaimed; close will also cause all descendant scopes to be closed, recursively. Another terminal operation is merge, which causes all resources associated with the scope to be re-parented to the parent scope. This can be useful if there is a need to extend the life-cycle of a given group of resources.

Unchecked scopes

The binder internals might sometimes need to create an heterogeneous region of memory in which multiple pointers provided by the user (e.g. as arguments to a native function) have to be stored. In such cases, there is no possible choice for the target memory region scope: the write operation is always bound to fail, given that there is no ancestor relationship between the scope associated with the heterogeneous array and the scope(s) associated with the user-defined pointers. In these cases it might be desirable for the binder to bypass the ownership mechanism, by associating the memory region with an unchecked scope.

Scopes and thread-safety

There are, in general, two kinds of scopes: shared scopes, which can be stored in a field and reused across multiple computations; and thread-confined scopes, typically used in combination with the try-with-resource construct inside the body of a method. The latter kind poses no challenges from a thread-safety perspective: since allocation of the scope is private to the executing thread, no contention is possible here. On the other hand, if we considered shared scopes, it is possible for two or more threads to access them concurrently. It is therefore crucial that the internal data structures used by a Scope are capable of handling concurrency. A failure to do so might leave a Scope in an undefined state (consider a case where one thread performs some allocation on a scope, while another thread is attempting to close such a scope).

We could tweak the API to distinguish between a thread-confined fork and a shared fork operation, and focus thread-safety management only on scopes returned by the latter factory. While this will lead to a more performant API (clients only pay for thread safety when needed), it is not clear as to whether the performance boost is worth the increase in terms of API footprint.

Scope API

We are now ready to show the Scope API in more details:

interface Scope extends AutoCloseable {
    <Z> Pointer<Z> allocate(LayoutType<Z> type);
    <Z> Array<Z> allocateArray(LayoutType<Z> type, int size);
    ... //other allocation functions

    Scope fork();

    void merge();
    void close();

    Scope parent();

    static Scope globalScope() { ... }

    interface Resource {
        Scope scope();
    }
}

As it can be seen, Scope supports the usual allocation operations, for allocating pointers, arrays, structs, etc. Then we have a Scope factory, namely fork, followed by the terminal operations merge and close (whose semantics has been explained above). There is an accessor to the parent scope (parent) and, finally, a static factory allows clients to retrieve the global scope. For completeness, the interface Resource is used to mark all resources owned by a scope (pointers, callbacks, libraries, etc.).

As we have seen, it is only safe to merge against a parent, which is why we have only allowed for a merge operation without a target scope parameter. We could in principle also support a target scope as in:

void merge(Scope target)

Under the conditions that the target scope is an ancestor of the scope being merged, and that the implementation of this method keeps merging against the parent until the target scope is reached. By doing so we avoid the pesky problems exposed in the earlier sections:

void merge(Scope target) {
     Scope scope = this;
     while (scope != target) {
         scope.merge();
         scope = scope.parent();
     }
}

As far as thread-safety is concerned, if we wanted to special case thread-confined scope allocation, we could also add the following fork operation:

Scope fork(Thread thread);

Which will return a Scope that can be used only by the specified thread (and throws otherwise).