Skip to content

Do not report a channel closed underneath us as EBADF on close - #9723

Open
aminmansuri wants to merge 4 commits into
jruby:jruby-10.0from
aminmansuri:fix-close-after-channel-interrupt
Open

aminmansuri wants to merge 4 commits into
jruby:jruby-10.0from
aminmansuri:fix-close-after-channel-interrupt

Conversation

@aminmansuri

Copy link
Copy Markdown
Contributor

Fixes #9722.

Thread#kill unblocks IO by interrupting the native thread, and the JDK closes the interruptible channel in response. Ruby never closed that stream, but the ensure that follows calls IO#close, and ChannelFD.finish() raised ClosedChannelException — surfacing as Errno::EBADF, which Thread#join then re-raises into the caller.

The close is now skipped when the channel has already gone. Two details:

  • isOpen() guards ch.close() only, not the branch, so the fileno bookkeeping still runs. It cannot be dropped either: FileDescriptorByteChannel.close() throws on a second close, and IOChannel.close() would call a user object twice.
  • unregisterWrapper now takes the wrapper and removes only if the fileno still maps to it. By key alone, a stale close evicts a live wrapper on a recycled descriptor and a later close closes an unrelated file.

The one-argument unregisterWrapper(int) is left in place, now unused and unsafe unless the caller owns the mapping — deprecating it seemed an API call to leave to you.

Evidence

Forcing test red before, green after. 800 reproduction rounds: 14 failures on Java 21 and 12 on Java 25 before, 0 after; CRuby 0 in 400. MRI net/http reproduces the defect on base at about 1 run in 10, absent across 10 runs after. test_io.rb 49, MRI ruby/test_io.rb 200, stdlib net socket io fiber 362 — all green; io, file and socket specs identical.

Limits

Only close is fixed. After the interrupt the stream still reports closed? == false and still fails write and select. And io.to_channel.close; io.close no longer raises Errno::EBADF, which CRuby does — a lost diagnostic.

The cause is the interrupt itself, as the FIXME at OpenFile.java:1466 says. Giving writeInternal the pre-select readInternal has would fix all four, at the cost of a select per write. Happy to take that on if you want it.

Thread#kill unblocks IO by interrupting the thread, and the JDK closes an
interruptible channel when it does. Ruby never closed that stream, so its
close must succeed rather than raise Errno::EBADF out of an ensure.
The early isOpen check skipped the whole close branch, so the last referrer
left its wrapper registrations behind. Drop the check rather than guard the
branch: Channel#close is a documented no-op on an already closed channel.
Close the channel only while it is open: a second close throws
IllegalStateException from some channels and re-enters a user object's close.
Unregister by identity so a stale close cannot evict a live wrapper that has
taken over a recycled fileno.
@aminmansuri

aminmansuri commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

My interest in the fix is primarily eradicating the build errors.
But the main motivation is that it is an inconsistency with CRuby's behavior. So fixing this makes CRuby compatibility better.

Comment thread test/jruby/test_io.rb Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants