While writing some error handling helpers, during my algorithm stream, I encountered a defect. It’s a tricky one dealing with the passing of shared arguments to functions in Leaf.
After a lot of searching I managed to reduce it to this minimal unit test.
/* EXPECT(3) */ var a : integer shared := 2 defn pine = (b : integer shared) -> { var c : integer shared := 3 b = c trace(b) } pine(a)
The function is silly, but it is sufficient to demonstrate an error. It manifests as severe sounding abort: duplicate-release-shared
error.
For the curious, the example was reduced from the below error reporting function.
@export defn print = ( ofi : βfail_info optional shared ) -> { println(["Error:"]) while has(ofi) { var q = getopt(ofi) println(["\t",q.tag]) ofi = q.next } }
That iterates over the tags in an error and prints them to the console. It mostly works, except for one issue on the ofi = q.next
line — it also causes that nasty duplicate-release-shared
error.
It’s a problem of ownership
The error is one of ownership: two code paths are freeing the same value. Consider this basic code:
var b : shared integer := 5 var c : shared integer := 6 b = c
b
and c
are both shared values. These are roughly equivalent to a shared_ptr<integer>
in C++, or the Integer
type in Java. When we assign b = c
we’re not modifying values, rather changing references. You may refer to my article on divorcing a name from its value for more details on that concept.
Leaf uses reference counting; this is roughly what happens when we assign to a shared variable:
// b = c acquire(c) release(b) assign(b, c)
We release the old object in b
, acquire the new one c
, and then assign the reference (a memory pointer).
Take a look back at the test case again, the b = c
comes from that example:
defn pine = (b : integer shared) -> { var c : integer shared := 3 b = c trace(b) }
The trouble here is that b
is an argument to the function, so we’re only allowed to release
it if we own the variable. It turns out we don’t!
Consider the calling side:
pine(a)
Reduced to pseudo-code, again based on reference counting:
var a_tmp = acquire(a) pine( a_tmp ) release( a_tmp )
This bit of code ensures that the reference to a
is valid during the function call. Since a
is a globally modifiable, we need to play it safe here. The problem is the above code calls release(a_tmp)
. But our pine
function is also doing release(b)
, which happens to be the same shared object as a_tmp
. Thus we’ve released the object twice!
Logically avoiding the issue
This issue can be avoided by shadowing all arguments locally in the function.
defn pine = ( _b : integer shared) -> { var b = _b var c : integer shared := 3 b = c trace(b) }
The shadowing works since the first thing the function does is acquire(_b)
, and the eventual release(b)
doesn’t touch the source argument.
It feels a bit like overkill to do this for all arguments on the off-chance they might be modified. I guess the compiler could scan the code and figure it out. Or, I could just disallow modifying function arguments. That’s how I arrived at my question Should function arguments be reassignable or mutable? .
For sanity in the compiler, I’ll likely add a fix (local shadowing), and forbid assigned to arguments by default. Then use some magic to try and optimize away all situations where it isn’t needed — though popular opinion seems to lean towards forbidding the modification of arguments.