The Life of a Programmer

Debugging a defect with a shared argument

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.

Please join me on Discord to discuss, or ping me on Mastadon.

Debugging a defect with a shared argument

A Harmony of People. Code That Runs the World. And the Individual Behind the Keyboard.

Mailing List

Signup to my mailing list to get notified of each article I publish.

Recent Posts