Issue #20314 has been updated by mame (Yusuke Endoh).
Indeed Solution 3 seems to be wrong. I think I must have misunderstood what ko1 was
saying.
Actually, I don't like neither Solutions 1 and 2. In the code example, I used
exceptions A and B just for clarity. In most real-world cases, both are `Timeout::Error`.
Swallowing `Timeout::Error` is a normal practice (to make `Timeout.timeout` end
gracefully), so I don't think it is "broken".
I think this is a design flaw of Timeout's API, but I don't know what we can do
for that. I guess we will just have to go with Solution 2 for now?
----------------------------------------
Bug #20314: Simultaneous Timeout expires may raise an exception after the block
https://bugs.ruby-lang.org/issues/20314#change-107133
* Author: mame (Yusuke Endoh)
* Status: Open
* Backport: 3.0: UNKNOWN, 3.1: UNKNOWN, 3.2: UNKNOWN, 3.3: UNKNOWN
----------------------------------------
Launchable reports `TestTimeout#test_nested_timeout` as a flaky test, and I reproduced it
as follows.
```ruby
require "timeout"
class A < Exception
end
class B < Exception
end
begin
Timeout.timeout(0.1, A) do
Timeout.timeout(0.1, B) do
nil while true
end
end
rescue A, B
p $! #=> #<A: execution expired>
# Exception B is raised after the above call returns
#=> test.rb:16:in `p': execution expired (B)
p :end # not reach
end
```
This is because the timer thread performs two consecutive `Thread#raise` to the target
thread.
I have discussed this with @ko1 and have come up with three solutions.
### Solution 1
When multiple nested Timeouts expire simultaneously, raise an exception for the outer-most
Timeout and let the inner Timeouts expire without throwing an exception. In the above
example, it would only raise A.
The problem with this approach is that if you are rescuing A in the inner block, it may
never ends:
```ruby
Timeout.timeout(0.1, A) do
Timeout.timeout(0.1, B) do
begin
sleep
rescue A
sleep # The exception A is caught. The inner Timeout is already expired, so the code
(may) never end.
end
end
end
```
Note that, if A and B did not occur at the same time, it would raise B. This is a race
condition.
### Solution 2
When multiple nested Timeouts expire simultaneously, raise an exception for the inner-most
Timeout and let the outer Timeouts wait until the inner-most Timeout returns. In the above
example, it would raise either A or B, not both.
The problem with this approach is that if you are rescuing B in the inner block, it never
ends:
```ruby
Timeout.timeout(0.1, A) do
Timeout.timeout(0.1, B) do
begin
sleep
rescue B
sleep # The outer Timeout waits for the inner timeout, and the inner Timeout never
return. So this code never ends.
end
end
end
```
### Solution 3
Make thread interrupt queue one length. If the target thread has already been
`Thread#raise(A)`, the new `Thread#raise(B)` blocks until the target thread processes A.
Since there will be no more simultaneous Thread#raise, there will be no more exceptions
after the end of the block. The timeout timer thread should be changed in consideration
that `Thread#raise` may block.
--
https://bugs.ruby-lang.org/