Help writing a timeout macro

I have a function that can either execute quickly or take a very long time. I want to kill the function after a certain amount of time so I can fall back on a more reliable method. I should be able to do this using a macro. The problem is I am a bit out of my depth. I took info from here and here and this is what I have so far

macro timeout(time,f)
    quote
        t = Task($f)
        schedule(t)
        Timer(x -> (istaskdone(t) || Base.throwto(t,InterruptException())),$time)
        t
    end
end

The idea being I can just do

a = @timeout 5 short_or_long(args)

but my implementation is woefully wrong. Could anyone help?

2 Likes

I don’t believe there is a way to interrupt a Julia task that does not explicitly yield control.
https://github.com/JuliaLang/julia/issues/6283

In HTTP.jl we have a timeout task that closes the network connection after a timeout. This causes the main task to abort with an EOF error next time it tries to use the connection. https://github.com/JuliaWeb/HTTP.jl/blob/master/src/TimeoutRequest.jl#L20-L27

Forgive my ignorance, but what does it mean to not explicitly yield control. Does this mean that a task that does explicitly yields control can be interrupted?

The way I understand it, a Julia task is it’s own master. If it calls yield (or sleep, or wait or some other API that ends up calling one of those), then it explicitly hands control to the Julia runtime. At all other times, a Julia task just executes compiled machine code like a compiled C program. i.e. there is no supervisor, or interpreter, or master thread, or any opportunity for preempting a Julia task.
https://github.com/JuliaLang/julia/issues/25353#issuecomment-354879008

Does this mean that a task that does explicitly yields control can be interrupted?

Yes

julia> t = @async try while true sleep(1) ; println("tick") ; end catch e println("stopped on $e") endtick
tick
tick
tick

julia> @async Base.throwto(t, EOFError())
stopped on EOFError()
Task (runnable) @0x000000011555b610

julia> t
Task (done) @0x000000011555b3d0
1 Like

I’ll just leave my approach here…

macro timeout(expr, seconds=-1, cb=(tsk) -> Base.throwto(tsk, InterruptException()))
    quote
        tsk = @task $expr
        schedule(tsk)

        if $seconds > -1
            Timer((timer) -> $cb(tsk), $seconds)
        end

        return fetch(tsk)
    end
end

julia> @timeout (sleep(3); println("done")) 3.1
done

julia> @timeout (sleep(3); println("done")) 3
ERROR: TaskFailedException:
InterruptException:
Stacktrace:
 [1] try_yieldto(::typeof(Base.ensure_rescheduled)) at ./task.jl:656
 [2] wait at ./task.jl:713 [inlined]
 [3] wait(::Base.GenericCondition{Base.Threads.SpinLock}) at ./condition.jl:106
 [4] _trywait(::Timer) at ./asyncevent.jl:110
 [5] wait at ./asyncevent.jl:128 [inlined]
 [6] sleep at ./asyncevent.jl:213 [inlined]
 [7] (::var"#29#31")() at ./task.jl:112
Stacktrace:
 [1] wait at ./task.jl:267 [inlined]
 [2] fetch(::Task) at ./task.jl:282
 [3] top-level scope at REPL[3]:10
2 Likes

Thanks, this was super helpful! I found two problems with this approach though. First, the return confusingly just terminates the expression; it doesn’t allow you to assign a variable to the output of the task (compare to x = return 4). Second, when you queue up multiple tasks in a row, the Timer from the old task is still active and will end up throwing the interrupt exception to the main program (not sure why that happens). Here’s my version, which fixes these issues (also switches the argument order)

macro timeout(seconds, expr)
    quote
        tsk = @task $expr
        schedule(tsk)
        Timer($seconds) do timer
            istaskdone(tsk) || Base.throwto(tsk, InterruptException())
        end
        fetch(tsk)
    end
end

x = @timeout 1 begin
    sleep(0.5)
    println("done")
    1
end
@assert x == 1
8 Likes

Thanks for this useful snippet.

I wanted something very similar but with the possibility of a default value in case of failure. So I came up with this:

macro timeout(seconds, expr, fail)
    quote
        tsk = @task $expr
        schedule(tsk)
        Timer($seconds) do timer
            istaskdone(tsk) || Base.throwto(tsk, InterruptException())
        end
        try
            fetch(tsk)
        catch _
            $fail
        end
    end
end

x = @timeout 1 begin
    sleep(1.1)
    println("done")
    1
end "failed"
1 Like

this is amazing guys. thanks!