[ANN] Package Conditions.jl

Conditions.jl is a proof-of-principle implementation of a Common Lisp like condition system.
In contrast to throwing an exception, signalling a condition does not unwind the stack. This allows a condition handler to resume execution, e.g.,

using Conditions

function seq()
    n = 1
    while n < 10
        @signal n  # Think: `yield n`
        n += 1
    end
end

handler_bind(Handler(Any, c -> @show c)) do
    seq()
end

See the package README for more examples and how restarts allow more elaborate error recovery than try-catch.

17 Likes

Looks great to me and adds a really nice feature from Common Lisp I have somewhat missed in Julia!

I also expected something like this to pop up soon since special bindings where just merged recently. However you don’t seem to use them and instead rely on task_local_storage. This has the limitation that handlers can’t be set across tasks but it would just be a small change to switch to ScopedValues as storage instead :smiley:

1 Like

Thanks, had not seen ScopedValues and thus took task_local_storage for special variables. Its certainly interesting if handlers could cross task boundaries … have not thought about the implications though.

Ok, have switched to ScopedValues … this is pretty cool actually as you can now drive several tasks on different threads from a single handler:

using Conditions

function seq(n)
    i = 1
    while i < n
        @restart_case @signal(i) begin
            :next => () -> nothing
        end
        @show Threads.threadid()
        i += 1
    end
end

function donext(c)
    sleep(0.01)
    @show c
    invoke_restart(find_restart(:next))
end
julia> @sync handler_bind(Handler(Any, donext)) do
           Threads.@spawn seq(3)
           Threads.@spawn seq(5)
       end
c = 1
Threads.threadid() = 2
c = 1
Threads.threadid() = 5
c = 2
Threads.threadid() = 2
c = 2
Threads.threadid() = 5
c = 3
Threads.threadid() = 5
c = 4
Threads.threadid() = 5
Task (done)
4 Likes

This looks kind of cool, but I can’t immediately grasp from your examples what you’d actually use this for. For example, Infiltrator is mentioned in the readme, but does it make a difference whether that is run from inside a function or from the outside, after the detour of a signal?

Not sure if I understand you correctly, but the basic difference between throw and @signal is that with the former, you can only end up lower on the stack inside some try-catch form, whereas the latter leaves the stack untouched and a handler from handler_bind runs on top of the current stack. This can be used in several ways:

  1. When toggle_interactive(true) there is a default handler which runs @infiltrate on top of the stack, i.e., you get dumped into a debug prompt exactly at the place where @signal was called (and not where it got caught/handled). It does not matter how you got there or where it was handled – if that’s what you meant with “does it make a difference whether that is run from inside a function or from the outside”.

  2. Using @restart_case you can mark positions on the stack which can be used to resume execution later on. In particular, a handler lower on the stack can decide to continue execution of the program on one of such restart points. Practical Common Lisp has a slightly larger example showing why and how this can be used for error recovery. For illustration, I have just ported this example.

In any case, thanks for taking a look.

3 Likes