Skip to content

Fix ClassCastException in ARGF.gets and readline when $stdin is not an IO - #9785

Merged
headius merged 2 commits into
jruby:jruby-10.0from
zev:argf-gets-non-io-stdin
Oct 15, 2026
Merged

headius merged 2 commits into
jruby:jruby-10.0from
zev:argf-gets-non-io-stdin

Conversation

@zev

@zev zev commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Conflicts with #9784. Whichever merges second needs a rebase that keeps both sides of the RubyArgsFile.readlines forwarding and of shared/gets.rb. See the rebase note for the verified resolution.

When $stdin isn't an IO (most often a StringIO swapped in by a test), ARGF.gets, ARGF.readline, Kernel#gets and Kernel#readline raise a Java ClassCastException. argf_getline cast the current file to RubyIO before checking whether it was generic input. Generic input is the branch that forwards to $stdin.gets for exactly this case.

$ jruby -rstringio -e '$stdin = StringIO.new("a\nb\n"); p gets'
Unhandled Java exception: java.lang.ClassCastException: class org.jruby.ext.stringio.StringIO cannot be cast to class org.jruby.RubyIO

Each row runs in a fresh process with no ARGV, after require "stringio"; $stdin = StringIO.new("a\nb\n"):

Call CRuby 4.1 JRuby before JRuby after
ARGF.gets "a\n" ClassCastException "a\n"
ARGF.gets(chomp: true) "a" ClassCastException "a"
gets "a\n" ClassCastException "a\n"
gets(chomp: true) "a" ClassCastException "a"
ARGF.readline "a\n" ClassCastException "a\n"
readline "a\n" ClassCastException "a\n"
gets; $_ "a\n" ClassCastException "a\n"
readline; $_ "a\n" ClassCastException "a\n"
ARGF.gets; ARGF.gets; [$., ARGF.lineno] [2, 2] ClassCastException [2, 2]
ARGF.readline; ARGF.readline; [$., ARGF.lineno] [0, 0] ClassCastException [0, 0]
readline; readline; [$., ARGF.lineno] [0, 0] ClassCastException [2, 2] (see below)
2.times { ARGF.readline }; ARGF.readline EOFError: end of file reached ClassCastException EOFError: End of file reached
2.times { readline }; readline EOFError: end of file reached ClassCastException EOFError: End of file reached
ARGF.readlines (unaffected) ["a\n", "b\n"] ["a\n", "b\n"] ["a\n", "b\n"]

With a plain object as $stdin, ARGF forwarded with a functional call that also reaches private methods. On a plain Object, those are the private Kernel#gets, Kernel#readline and Kernel#readlines, which read from ARGF again:

$stdin is Call CRuby 4.1 JRuby before JRuby after
Object.new ARGF.gets NoMethodError (private gets) ClassCastException NoMethodError (private gets)
Object.new ARGF.readlines NoMethodError (private readlines) StackOverflowError NoMethodError (private readlines)
object with only gets ARGF.readline NoMethodError (private readline) ClassCastException NoMethodError (private readline)

The EOFError message differs only in capitalization. That message comes from JRuby's StringIO#readline, not from ARGF.

Changes

RubyArgsFile only:

  • argf_getline: the (RubyIO) cast moves into the non-generic branch.
  • argf_getline: when the forwarded gets returns a line and the current file isn't a RubyIO, increment the line number. MRI's argf_getline does ARGF.lineno = ++lineno for every line, generic input included. A real IO such as STDIN already updates $. itself, and JRuby also treats STDIN as generic input, so it's excluded to avoid counting twice.
  • ARGF.readline: when the current file isn't a RubyIO, forward to $stdin.readline (with keywords) instead of going through gets. MRI's argf_readline does this with ARGF_FORWARD. That's why CRuby doesn't count these lines in ARGF.lineno, and why the forwarded readline raises its own EOFError.
  • Forwarding gets, readline and readlines to $stdin now uses NormalCachingCallSites in ArgfSites, so only public methods are called. That matches MRI's forward_current, which uses rb_funcallv_public_kw. Before, it used callMethod, which also reaches private methods. With a plain object as $stdin, that found the private Kernel#gets and friends and recursed. readlines already overflowed the stack this way on jruby-10.0. This is the second commit (b64041f).

Not changed: Kernel#readline

MRI's rb_f_readline calls ARGF.readline, so in CRuby a non-IO $stdin gets $stdin.readline and the line isn't counted. JRuby's Kernel#readline calls Kernel#gets and raises EOFError on nil, so after this PR it calls $stdin.gets and counts the line (the bold row above). Everything else in that row matches: the lines returned, $_ and EOFError.

Matching MRI exactly would mean routing Kernel#readline through ARGF.readline. I left Kernel#readline alone because it's widely used. The proposed patch, its measured effects and its risk are in this comment for discussion.

Testing

  • New examples:
    • shared/gets.rb (ARGF.gets and ARGF.readline): reading each line, and passing keywords on.
    • gets_spec.rb: lineno is incremented.
    • readline_spec.rb: readline is forwarded to $stdin with the separator, limit and keywords (checked with a mock), and EOFError at the end of $stdin.
    • gets_spec.rb, readline_spec.rb and readlines_spec.rb: NoMethodError when $stdin has no public gets, readline or readlines.
    • They use ARGF.class.new with no arguments, which is stream mode. MRI only supports a non-IO $stdin in stream mode. With "-" in ARGV, MRI's next_argv calls GetOpenFile on it and a StringIO raises IOError: uninitialized stream.
  • The new examples pass on CRuby 4.0.7 and 4.1.0dev. On unmodified jruby-10.0, all 11 new example runs fail, with a ClassCastException or (for readlines) a StackOverflowError.
  • spec/ruby/core/{io,file,enumerator,argf,kernel} and library/stringio with mspec ci: the same 2 failures as unmodified jruby-10.0 (Kernel#require ... c-extension file exists but loading fails, unrelated).

Question: keep this separate or fold it into #9784?

This is a small fix for a crash in plain gets, so I've kept it separate from #9784 (ARGF separator, limit and keyword handling, which depends on #9783). The two will conflict a little: both touch argf_getline's neighbors, ARGF.readline, and spec/ruby/core/argf/shared/gets.rb. Would you rather take this on its own, or have me fold it into #9784?

Other ARGF methods also cast the current file to RubyIO and fail with a non-IO $stdin on jruby-10.0. CRuby handles all of these:

Call CRuby 4.1 JRuby
ARGF.readpartial(1), ARGF.read_nonblock(1) EOFError (forwarded) ClassCastException
ARGF.to_io NoMethodError (no StringIO#to_io) ClassCastException
ARGF.fileno nil ClassCastException
ARGF.pos, ARGF.rewind 0 ClassCastException
ARGF.eof?, ARGF.eof false true (wrong, no exception)
ARGF.binmode returns $stdin returns ARGF

I can fix these here, in #9784, or in a follow-up. Let me know which you'd prefer.

🤖 Generated with Claude Code

@zev

zev commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Proposal for discussion: route Kernel#readline through ARGF.readline

This PR deliberately doesn't change Kernel#readline, since it's so widely used. Here's the change that would make it match MRI exactly, for discussion. @headius, you know this area better than we do: if you think it's safe, I'll add it to this PR. If not, we'll leave it out.

diff --git a/core/src/main/java/org/jruby/RubyKernel.java b/core/src/main/java/org/jruby/RubyKernel.java
index 5d656b9ede..9c5c592622 100644
--- a/core/src/main/java/org/jruby/RubyKernel.java
+++ b/core/src/main/java/org/jruby/RubyKernel.java
@@ -833,15 +833,14 @@ public class RubyKernel {
         return context.nil;
     }
 
+    // MRI: rb_f_readline
     @JRubyMethod(optional = 1, keywords = true, checkArity = false, module = true, visibility = PRIVATE)
     public static IRubyObject readline(ThreadContext context, IRubyObject recv, IRubyObject[] args) {
-        IRubyObject line = gets(context, recv, args);
-
-        if (line.isNil()) {
-            throw context.runtime.newEOFError();
-        }
+        var ArgsFile = argsFile(context);
 
-        return line;
+        return recv == ArgsFile ?
+                RubyArgsFile.readline(context, ArgsFile, args) :
+                sites(context).readline.call(context, ArgsFile, ArgsFile, args);
     }
 
     @JRubyMethod(optional = 1, keywords = true, checkArity = false, module = true, visibility = PRIVATE)
diff --git a/core/src/main/java/org/jruby/runtime/JavaSites.java b/core/src/main/java/org/jruby/runtime/JavaSites.java
index 22c3565174..2d3238ee02 100644
--- a/core/src/main/java/org/jruby/runtime/JavaSites.java
+++ b/core/src/main/java/org/jruby/runtime/JavaSites.java
@@ -96,6 +96,7 @@ public class JavaSites {
         public final CallSite to_str = new FunctionalCachingCallSite("to_str");
         public final CallSite getc = new FunctionalCachingCallSite("getc");
         public final CallSite gets = new FunctionalCachingCallSite("gets");
+        public final CallSite readline = new FunctionalCachingCallSite("readline");
         public final CallSite putc = new FunctionalCachingCallSite("putc");
         public final CallSite puts = new FunctionalCachingCallSite("puts");
         public final CallSite initialize_copy = new FunctionalCachingCallSite("initialize_copy");

What it changes. Today Kernel#readline calls Kernel#gets, which dispatches ARGF.gets, and then raises EOFError on nil. MRI's rb_f_readline calls ARGF.readline instead: argf_readline directly when recv == argf, otherwise forward(argf, rb_intern("readline"), ...). The patch mirrors Kernel#gets (RubyKernel.java:351-357) with readline in place of gets. That means a new FunctionalCachingCallSite("readline") in KernelSites, next to the existing gets site. Nothing in JRuby calls RubyKernel.readline from Java.

Measured. Each case runs in a fresh process with file containing "a\nb\n". Columns: unmodified jruby-10.0, this PR as pushed, and this PR plus the patch.

Case CRuby 4.1 jruby-10.0 this PR this PR + patch
file: readline, readline(chomp: true), $_, [$., ARGF.lineno], EOFError — same as CRuby same same
piped STDIN: readline twice, [$., ARGF.lineno], EOFError — same as CRuby same same
StringIO $stdin: readline; readline; [$., ARGF.lineno] [0, 0] ClassCastException [2, 2] [0, 0]
StringIO $stdin: readline; $_ / EOFError at end "a\n" / EOFError ClassCastException same as CRuby same as CRuby
$stdin object with gets but no public readline NoMethodError (private readline) ClassCastException "g\n" NoMethodError (private readline)
def ARGF.gets(*) = "patched gets\n"; readline "a\n" "patched gets\n" "patched gets\n" "a\n"
def ARGF.readline(*) = "patched readline\n"; readline "patched readline\n" "a\n" "a\n" "patched readline\n"

(The EOFError message capitalization, "End of file reached", is the same in all JRuby columns and unrelated.)

With the patch, mspec ci over spec/ruby/core/{io,file,enumerator,argf,kernel} and library/stringio gives the same result as without it (2 unrelated Kernel#require failures, also on jruby-10.0).

The risk, as I see it. For reading files and real STDIN, nothing changes. The visible change is which ARGF method Kernel#readline dispatches to: ARGF.readline instead of ARGF.gets (the last two rows). That matches CRuby. But code that redefines or stubs ARGF.gets to feed a top-level readline (for example allow(ARGF).to receive(:gets) in a test) would stop working on JRuby. Code that stubs ARGF.readline would start working. I don't know how common the former is in JRuby-specific code.

Depends on b64041f. While testing this patch I found that ARGF's forwarding to a non-IO $stdin used a functional call, which can reach private methods. With the patch, a $stdin that has gets but no public readline sent ARGF.readline → private Kernel#readline → ARGF.readline → ... and overflowed the stack. That same commit also fixes overflows this PR (and, for readlines, unmodified jruby-10.0) already had with a plain Object as $stdin. ARGF's forwarding now uses NormalCachingCallSites, the equivalent of MRI's rb_funcallv_public_kw in forward_current, and raises NoMethodError like CRuby. So the patch above is only safe on top of that commit.

@zev

zev commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Rebase note: #9784 and #9785 conflict

Whichever of #9784 and #9785 merges second needs a rebase. I trial-merged argf-gets-non-io-stdin (#9785) into argf-enumerator-keywords (#9784). There are two conflicts, and the resolution is to keep both sides in each:

1. RubyArgsFile.readlines, forwarding to a non-IO $stdin. #9784 restores callInfo so keywords reach $stdin.readlines. #9785 switches the call to a public-only call site so a plain-object $stdin doesn't recurse into the private Kernel#readlines. The resolved code needs both:

        if (!(data.currentFile instanceof RubyIO)) {
            context.callInfo = callInfo; // restore callInfo for kwargs
            return sites(context).readlines.call(context, recv, data.currentFile, args);
        }

Don't keep only #9784's callMethod(context, "readlines", args). That brings back the StackOverflowError with $stdin = Object.new. Don't keep only #9785's line either, because keywords would then be passed as a positional Hash.

2. spec/ruby/core/argf/shared/gets.rb. Both PRs add examples at the same spot. Keep both sets: #9784's chomp/separator/limit examples, then #9785's when reading $stdin and $stdin is not an IO block.

ARGF.readline auto-merges correctly. It gets #9784's optional = 2 annotation and #9785's forwarding to $stdin.readline.

Leave as is: eachLineCommon in #9784 forwards each_line with callMethod, which can reach private methods. Unlike gets, readline and readlines, that's correct. MRI's argf_each_line uses rb_block_call_kw → iterate_method → CALL_FCALL (vm_eval.c), not forward_current's public call, so it shouldn't be switched to a NormalCachingCallSite.

Verified: with this resolution, the trial merge builds, all spec/ruby/core/argf specs pass on CRuby 4.1.0dev and JRuby (186 examples), and mspec ci over core/{io,file,enumerator,argf,kernel} and library/stringio shows only the 2 unrelated Kernel#require failures that jruby-10.0 also has.

@headius headius left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. Glad to see the move to using a call site instead of uncached callMethod.

@headius headius added this to the JRuby 10.0.8.0 milestone Oct 8, 2026
@zev

zev commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@headius thanks for the review. One open question from earlier: should I add the Kernel#readline change from this comment (routing it through ARGF.readline to match MRI)? If you'd rather not touch Kernel#readline, I'll leave it out and mark this ready as is.

zev and others added 2 commits October 12, 2026 22:32
When $stdin was not an IO (for example a StringIO), ARGF.gets, ARGF.readline,
Kernel#gets and Kernel#readline raised a Java ClassCastException because
argf_getline cast the current file to RubyIO before checking for generic
input. The cast now happens only for real IO.

To match MRI, lines read through ARGF.gets from such a $stdin are counted
in ARGF.lineno and $., and ARGF.readline forwards to $stdin.readline as
MRI's argf_readline does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ARGF forwarded gets, readline and readlines to a $stdin that is not an IO
with a functional call, which also reaches private methods. On a plain
object those are Kernel#gets, Kernel#readline and Kernel#readlines, which
read from ARGF again and recurse until StackOverflowError. MRI's
forward_current uses rb_funcallv_public_kw and raises NoMethodError, so use
call sites with normal visibility checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@zev
zev force-pushed the argf-gets-non-io-stdin branch from b64041f to 0f44db8 Compare October 13, 2026 03:33
@zev

zev commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@headius two questions before this merges.

1. NormalCachingCallSite deprecation warnings. CI shows three [deprecation] NormalCachingCallSite ... has been deprecated warnings at JavaSites.java:490-492. Those are the new ArgfSites gets/readline/readlines sites from this PR. The class is @Deprecated(since = "9.4-") and its javadoc says it was replaced by MonomorphicCallSite, which also uses CallType.NORMAL, so the swap doesn't change behavior (public-only dispatch).

Should I switch these three to MonomorphicCallSite in this PR? I'd lean yes. Separately, you asked about updating all of them in a new PR: a grep of origin/master under core/src/main/java finds no other users of NormalCachingCallSite besides the class itself, so this PR's three would be the only ones. A follow-up PR would only be needed to remove the deprecated class.

2. Kernel#readline. Do you want it routed through ARGF.readline to match MRI's rb_f_readline? The patch and measurements are in this comment. The tradeoff is that code stubbing ARGF.gets to feed a top-level readline would stop working, and code stubbing ARGF.readline would start working. If you'd rather not touch Kernel#readline, I'll leave it out.

Marking this ready for review now.

@zev
zev marked this pull request as ready for review October 15, 2026 04:28
@headius

headius commented Oct 15, 2026

Copy link
Copy Markdown
Member

Let's err in the direction of keeping things moving and do the additional cleanup separately.

I support both the Kernel.readline => ARGF move and the deprecation cleanups.

@headius
headius merged commit 2724a0d into jruby:jruby-10.0 Oct 15, 2026
127 checks passed
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