I'm implementing Go's panic handling in GoSSR's interpreter. I've still got a known-bug test for a recovered panic skipping the remaining defers. I can see the recovery happen, so why doesn't the function finish? I'm working backwards from a smaller Go program to see what I've left out.

Go's defer saves a call to run when the surrounding function leaves, including during a panic. A deferred function can use recover to stop that panic propagating into the caller. A function runs its defers in reverse registration order. Register a print first, then a recovering closure, and the recovering closure runs first:

func finish() {
    defer fmt.Println("last")
    defer func() {
        if recover() != nil {
            fmt.Println("recovered")
        }
    }()
    panic("unfinished")
}

In Go, the output is recovered, followed by last. (I could easily count “recovered” as success and stop there.) The unwinder leaves calls and runs their defers while a panic propagates. A tempting unwinder would stop there too:

while there are deferred calls:
    run the next one
    if the panic has been recovered:
        return

I'm done, well, I've stopped the panic.

The earlier print is waiting, so I have to run the remaining defers before returning.

I can follow the whole thing without a web page or a large error handler around it. I expect two lines. If I lose the second, I know which bit of leaving the function still needs work. “Panic is broken” leaves a lot less to go on.

I enjoy these tiny programs.

No setup to get through, no database to start, and I can keep the whole thing in my head while changing it. I'd happily spend longer playing with this one.

The answer is still under construction

The missing print is one thing to fix, but I'm less sure where to put the result handling because a defer can still change the answer. What does this one return?

func answer() (n int) {
    defer func() {
        if recover() != nil {
            n = 42
        }
    }()
    n = 7
    panic("unfinished")
}

The deferred function recovers the panic and replaces the seven in the named result with forty-two. That's what the caller receives when the function returns in Go. Execution does not resume after the call to panic.

Each active call has a frame holding its locals. It also records the work that call still owes before returning.

I'd normally hand over the answer and reclaim the space when leaving a function, but here the answer is seven, then it isn't. The frame begins leaving while the deferred closure is still allowed to change it. I can't pack everything away as soon as something panics.

I'm using Go's panic and recovery rules to work out the order here. Writing a defer doesn't make me think about when a named result gets copied, but implementing it leaves me with the slightly irritating requirement to keep an answer that isn't ready to be an answer yet.

Whose result is it?

A named result is a variable belonging to this invocation. Here the deferred closure captures n, using the same shared-variable relationship as the closure example, so the named result stays with the function being left. The caller's destination can wait. Copying seven there early would only give me another place to remember to update when the defer changes n. I keep the return destination, pending deferred calls and locals together in the frame. A panic may take me past the normal return instruction, but the work is still there to finish.

Save the arguments when defer runs

What do I save when the function first reaches a defer? Do I keep the argument's value, or an expression to evaluate later? I can try that without a panic:

func printLater() {
    n := 1
    defer fmt.Println(n)
    n = 2
}

Go evaluates the arguments when the defer statement executes, so it saves the value one. The call still waits until the function leaves, then prints that saved value.

Compare that with a deferred closure:

func readLater() {
    n := 1
    defer func() { fmt.Println(n) }()
    n = 2
}

This time it prints two. I'm deferring a closure whose body reads n when it eventually runs. There aren't any arguments to save when I register it.

Compare the two saved calls

I'd keep these two functions beside the implementation. “Save something to run later” sounds like one job, but one example needs the argument's value now and the other needs continued access to a variable. Saving the expression and evaluating it at exit would make the first behave like the second. I would have made defer simpler by changing what it means.

Finish the return, or keep unwinding

Keep the frame while all pending defers run, even after recovery. Then check whether a panic is still active. If it is, unwind into the caller. Otherwise, deliver the final result.
Figure 1 - A frame can still owe work. The arrows show the order of work as the function exits.

What happens when the defers finish? A recovered panic lets this function complete its return. A panic that's still active sends the unwinding work into the function that called this one. I can't use one exit for both and hope the caller works out which happened. So can I relax once the panic flag is clear? Not yet. I still need the current frame, its remaining defers and its result destination. Getting them together gives me a way to finish the function even though the panic bypassed its ordinary return instruction.

For answer, the frame finally has forty-two ready for the caller. Now I can lend its space to another call.

Seven is a perfectly good integer. It is still the wrong answer here.

The answer is 42. Getting it back to the caller is apparently the difficult bit. Watch on YouTube.

This entry came over from blog.haddon.me. I’ve tidied up the grammar and flow, bringing it closer to how I write now. I used AI to help with the grammar, structure and flow. I have dyslexia, and my writing often comes out as a rambling scrawl, so it can be hard to see what I was actually working on at the time. I think the tidied versions are easier to digest and more interesting to read. The artefacts got the same tidy-up while I was at it.

  • Related article

    The variables are dead, Dave

    I'm trying to lend calls some space and take it back. Closures are making that arrangement less simple than I'd like.