|
3 | 3 | # Test that rb_fiber_scheduler_blocking_operation_wait correctly handles |
4 | 4 | # exceptions raised by the scheduler's blocking_operation_wait method. |
5 | 5 | # |
6 | | -# When the scheduler raises (e.g. due to fiber interrupt), the exception |
7 | | -# propagates through rb_funcall in the C function, bypassing the cleanup |
8 | | -# code that nulls out operation->function, operation->state, etc. |
9 | | -# operation->state points to a stack-allocated struct in the caller |
10 | | -# (rb_nogvl), so after the exception unwinds that frame the pointer is |
11 | | -# dangling. Any subsequent dereference — including from dfree during GC |
12 | | -# or a re-execution of the operation — causes a segfault. |
| 6 | +# When blocking_operation_wait raises, rb_funcall propagates the exception, |
| 7 | +# bypassing the cleanup code that nulls out operation->function/state/data. |
| 8 | +# operation->state points to a stack-allocated struct in the caller (rb_nogvl), |
| 9 | +# so after the exception unwinds that caller's frame the pointer is dangling. |
| 10 | +# Any subsequent dereference of those fields (e.g. from a scheduler that |
| 11 | +# tracks and cancels blocking operations during io_close) causes a segfault. |
13 | 12 |
|
14 | 13 | require "test/unit" |
15 | 14 | require_relative "scheduler" |
16 | 15 |
|
17 | 16 | class TestBlockingOperationException < Test::Unit::TestCase |
18 | | - # Scheduler whose blocking_operation_wait raises after completing the work, |
19 | | - # simulating a fiber interrupt arriving after the operation finishes. |
20 | | - class InterruptingScheduler < Scheduler |
| 17 | + # Scheduler that raises immediately from blocking_operation_wait without |
| 18 | + # executing the operation — the simplest possible trigger for the bug. |
| 19 | + class RaisingScheduler < Scheduler |
21 | 20 | def blocking_operation_wait(blocking_operation) |
22 | | - super |
23 | 21 | raise Interrupt, "simulated fiber interrupt" |
24 | 22 | end |
25 | 23 | end |
26 | 24 |
|
27 | | - # Reproduce the tcp_socket.rb pattern: a blocking IO operation (large |
28 | | - # IO::Buffer copy) raises through the scheduler, then we close a socket. |
29 | | - # If the exception corrupted scheduler state, server.close may crash. |
30 | | - def test_blocking_operation_exception_with_socket_close |
| 25 | + def test_blocking_operation_wait_exception |
31 | 26 | skip "IO::Buffer not available" unless defined?(IO::Buffer) |
32 | 27 |
|
33 | | - size = 2 * 1024 * 1024 # 2 MiB — large enough to trigger blocking_operation_wait |
34 | | - source = IO::Buffer.new(size) |
35 | | - dest = IO::Buffer.new(size) |
36 | | - source.clear(65) # fill with 'A' |
37 | | - |
38 | | - server = TCPServer.new("127.0.0.1", 0) |
39 | | - caught = [] |
40 | | - |
41 | | - # Run the blocking operation that raises inside a scheduled thread |
42 | | - Thread.new do |
43 | | - Fiber.set_scheduler(InterruptingScheduler.new) |
44 | | - Fiber.schedule do |
45 | | - dest.copy(source, 0, size, 0) |
46 | | - rescue Interrupt => e |
47 | | - caught << e |
48 | | - end |
49 | | - end.join |
50 | | - |
51 | | - assert_equal 1, caught.size, "Expected exactly one Interrupt" |
52 | | - |
53 | | - # After the exception propagated through rb_fiber_scheduler_blocking_operation_wait, |
54 | | - # operation->state is a dangling pointer. Trigger GC and then close a socket: |
55 | | - # if the dangling pointer is somehow dereferenced during these operations, |
56 | | - # ASAN/valgrind/the OS will detect it. |
57 | | - GC.start(full_mark: true, immediate_sweep: true) |
58 | | - GC.compact if GC.respond_to?(:compact) |
59 | | - |
60 | | - server.close # mirrors tcp_socket.rb:48 |
61 | | - |
62 | | - assert_equal size, dest.size |
63 | | - ensure |
64 | | - server&.close rescue nil |
65 | | - end |
66 | | - |
67 | | - def test_blocking_operation_exception_does_not_corrupt_state |
68 | | - skip "IO::Buffer not available" unless defined?(IO::Buffer) |
69 | | - |
70 | | - size = 2 * 1024 * 1024 |
| 28 | + size = 2 * 1024 * 1024 # 2 MiB — triggers the scheduler hook via rb_nogvl |
71 | 29 | source = IO::Buffer.new(size) |
72 | 30 | dest = IO::Buffer.new(size) |
73 | 31 | source.clear(65) |
74 | 32 |
|
75 | 33 | caught = [] |
76 | 34 |
|
77 | 35 | Thread.new do |
78 | | - Fiber.set_scheduler(InterruptingScheduler.new) |
| 36 | + Fiber.set_scheduler(RaisingScheduler.new) |
79 | 37 | Fiber.schedule do |
80 | 38 | dest.copy(source, 0, size, 0) |
81 | 39 | rescue Interrupt => e |
82 | 40 | caught << e |
83 | 41 | end |
84 | 42 | end.join |
85 | 43 |
|
86 | | - assert_equal 1, caught.size |
| 44 | + assert_equal 1, caught.size, "Expected the Interrupt to propagate to Ruby" |
| 45 | + |
| 46 | + # Force GC to run dfree on the blocking_operation TypedData. |
| 47 | + # With ASAN or a scheduler that dereferences operation->state during |
| 48 | + # cleanup (e.g. io-event's io_close), this is where the crash manifests. |
87 | 49 | GC.start(full_mark: true, immediate_sweep: true) |
88 | 50 | GC.compact if GC.respond_to?(:compact) |
89 | | - assert_equal size, dest.size |
90 | 51 | end |
91 | 52 | end |
0 commit comments