Project

General

Profile

Actions

Bug #22456

closed

IO#close hangs forever when another thread is writing to the IO from a trap handler (please backport ba68343d3a to 3.4)

Bug #22456: IO#close hangs forever when another thread is writing to the IO from a trap handler (please backport ba68343d3a to 3.4)

Added by txapelgorri (Ibon Castilla) 1 day ago. Updated about 17 hours ago.

Status:
Closed
Assignee:
-
Target version:
-
ruby -v:
ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [x86_64-linux]
[ruby-core:127175]

Description

Description

If a thread is blocked writing to an IO inside a trap handler, and another thread closes that IO, IO#close never returns. The writer gets IOError: stream closed in another thread and moves on. The closing thread stays in IO#close forever (status sleep).

This is already fixed on master by ba68343d3a "Allow wakeup mutex to be used in trap context" (GitHub PR #13684, released in 4.0.0). It has no Redmine ticket, and it was never backported. Both 3.4.11 and 3.3.12 still have the old code: thread_io_wake_pending_closer locks busy->wakeup_mutex with rb_mutex_lock, and rb_notify_fd_close creates that mutex without rb_mutex_allow_trap.

The waiting on close came to 3.3 with the backport of #20723, so the hang is a regression in 3.3 and 3.4.

Reproduction

No gems needed:

# IO#close hangs forever when the thread writing to that IO is running a trap handler.
# Usage: ruby repro.rb [trap|thread]
mode = ARGV[0] || "trap"

r, w = IO.pipe
begin
  loop { w.write_nonblock("x" * 4096) } # fill the pipe so the next write blocks
rescue IO::WaitWritable
end

closer = nil
write = lambda do
  closer = Thread.new { sleep 0.3; w.close; :closed } # close while the other thread is blocked writing
  begin
    w.syswrite("y")
    puts "write: returned"
  rescue Exception => e
    puts "write: #{e.class}: #{e.message}"
  end
end

if mode == "trap"
  Signal.trap("USR1") { write.call }
  Process.kill("USR1", Process.pid)
  sleep 0.1 until closer
else
  write.call
end

result = closer.join(3)
puts "#{RUBY_DESCRIPTION}, #{mode}: " +
  (result ? "IO#close returned" : "IO#close HANGS (#{closer.status}) at #{closer.backtrace&.first}")

Output (Debian 13, x86_64):

$ ruby repro.rb trap
write: IOError: stream closed in another thread
ruby 3.3.10 (2025-10-23 revision 343ea05002) [x86_64-linux], trap: IO#close HANGS (sleep) at repro.rb:13:in `close'

$ ruby repro.rb thread
write: IOError: stream closed in another thread
ruby 3.3.10 (2025-10-23 revision 343ea05002) [x86_64-linux], thread: IO#close returned

$ ruby repro.rb trap
write: IOError: stream closed in another thread
ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [x86_64-linux], trap: IO#close HANGS (sleep) at repro.rb:13:in 'IO#close'

$ ruby repro.rb thread
write: IOError: stream closed in another thread
ruby 3.4.10 (2026-06-30 revision 2b0b7728dc) +PRISM [x86_64-linux], thread: IO#close returned

Same with the latest releases of each branch (trap mode):

ruby 3.3.12 (2026-07-16 revision 0581089df9) [x86_64-linux], trap: IO#close HANGS (sleep) at repro.rb:13:in `close'
ruby 3.4.11 (2026-09-23 revision 592f1ffdb3) +PRISM [x86_64-linux], trap: IO#close HANGS (sleep) at repro.rb:13:in 'IO#close'
ruby 4.0.0 (2025-12-25 revision 553f1675f3) +PRISM [x86_64-linux], trap: IO#close returned

IO#write / << behave the same as syswrite.

Why it hangs (3.4)

rb_notify_fd_close moves the writer's waiting_fd to busy->pending_fd_users, and rb_notify_fd_close_wait sleeps on busy->wakeup_mutex until that list is empty. The writer removes itself in thread_io_wake_pending_closer, which first calls rb_mutex_lock(wfd->busy->wakeup_mutex). In trap context, do_mutex_lock refuses the lock ("can't be called from trap context"), so rb_mutex_lock raises before ccan_list_del() and rb_thread_wakeup(). The entry is never removed, and the closing thread is never woken. Because the waiting_fd lives on the writer's C stack, the list is also left pointing at a dead frame.

The exception that surfaces is the pending IOError, not a ThreadError. With a 3.4.10 build instrumented around that call, do_mutex_lock takes the trap branch, and rb_mutex_lock returns with TAG_RAISE and IOError in errinfo. TracePoint(:raise) sees no ThreadError at all.

Real-world impact

This is the hang that ba68343d3a was written for: Puma stopping on SIGTERM. In single mode, Puma runs the whole graceful stop inside its SIGTERM trap. Server#stop writes to its notification pipe (notify_safely) while the server thread closes that pipe in handle_servers. The server thread hangs in IO#close (puma/server.rb:432), the main thread hangs in join (server.rb:668) inside the trap, and systemd ends up sending SIGKILL.

We hit it in production with Puma 7.2.1 and Solid Queue running inside Puma, on Ruby 3.3.10. Five of seven stops hung. A thread dump taken during the hang showed exactly those two frames. The puma/puma#3940 change (syswrite instead of <<) does not avoid it, because the writer is still in a trap.

As a workaround, we changed Puma's TERM/INT traps to Thread.new { stop }, so that the stop runs outside trap context. With that change, five of five stops were clean.

Suggested backport (3.4 / 3.3)

The fix on master is in rb_thread_io_close_interrupt, which does not exist on these branches: master moved to per-IO blocking operations in 425fa0aeb5 (GH-13127) before the fix, so ba68343d3a does not cherry-pick. The equivalent change is in rb_notify_fd_close (thread.c). It mirrors what io.c already does for fptr->write_lock. Patch against 3.4.10 (attached as allow-trap-3.4.patch):

     if (has_any) {
         wakeup_mutex = rb_mutex_new();
         RBASIC_CLEAR_CLASS(wakeup_mutex); /* hide from ObjectSpace */
+        rb_mutex_allow_trap(wakeup_mutex, 1);
     }

Tested on Debian 13, x86_64, with the script above (trap mode, 20 runs each):

Ruby Result
3.4.10, unpatched IO#close hangs 20/20
3.4.10 + this patch (built from the release tarball) IO#close returns 20/20
4.0.7 (already contains ba68343d3a) IO#close returns 20/20

thread mode returns in all three. With the patch, make test-all TESTS="ruby/test_io.rb ruby/test_thread.rb ruby/test_signal.rb ruby/test_thread_queue.rb" passes: 406 tests, 8899 assertions, 0 failures, 0 errors, 1 skip.

The same hunk should apply to 3.3, where rb_notify_fd_close is identical. Only the line numbers change. I have not built it on 3.3.


Files

allow-trap-3.4.patch (279 Bytes) allow-trap-3.4.patch txapelgorri (Ibon Castilla), 10/19/2026 03:08 PM
repro.rb (853 Bytes) repro.rb txapelgorri (Ibon Castilla), 10/19/2026 03:08 PM

Updated by nobu (Nobuyoshi Nakada) about 17 hours ago Actions #1

  • Description updated (diff)
  • Status changed from Open to Closed
  • Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: REQUIRED, 3.4: REQUIRED, 4.0: DONE
Actions

Also available in: PDF Atom