Repository navigation
Fix ClassCastException in ARGF.gets and readline when $stdin is not an IO - #9785
Conversation
Proposal for discussion: route
|
| 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.
|
headius
left a comment
There was a problem hiding this comment.
Looks good. Glad to see the move to using a call site instead of uncached callMethod.
|
@headius thanks for the review. One open question from earlier: should I add the |
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>
b64041f to
0f44db8
Compare
|
@headius two questions before this merges. 1. Should I switch these three to 2. Marking this ready for review now. |
|
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. |
Warning
Conflicts with #9784. Whichever merges second needs a rebase that keeps both sides of the
RubyArgsFile.readlinesforwarding and ofshared/gets.rb. See the rebase note for the verified resolution.When
$stdinisn't an IO (most often aStringIOswapped in by a test),ARGF.gets,ARGF.readline,Kernel#getsandKernel#readlineraise a JavaClassCastException.argf_getlinecast the current file toRubyIObefore checking whether it was generic input. Generic input is the branch that forwards to$stdin.getsfor exactly this case.Each row runs in a fresh process with no ARGV, after
require "stringio"; $stdin = StringIO.new("a\nb\n"):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.readlineEOFError: end of file reachedClassCastExceptionEOFError: End of file reached2.times { readline }; readlineEOFError: end of file reachedClassCastExceptionEOFError: End of file reachedARGF.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 plainObject, those are the privateKernel#gets,Kernel#readlineandKernel#readlines, which read from ARGF again:$stdinisObject.newARGF.getsNoMethodError(privategets)ClassCastExceptionNoMethodError(privategets)Object.newARGF.readlinesNoMethodError(privatereadlines)StackOverflowErrorNoMethodError(privatereadlines)getsARGF.readlineNoMethodError(privatereadline)ClassCastExceptionNoMethodError(privatereadline)The EOFError message differs only in capitalization. That message comes from JRuby's
StringIO#readline, not from ARGF.Changes
RubyArgsFileonly:argf_getline: the(RubyIO)cast moves into the non-generic branch.argf_getline: when the forwardedgetsreturns a line and the current file isn't aRubyIO, increment the line number. MRI'sargf_getlinedoesARGF.lineno = ++linenofor every line, generic input included. A real IO such asSTDINalready updates$.itself, and JRuby also treatsSTDINas generic input, so it's excluded to avoid counting twice.ARGF.readline: when the current file isn't aRubyIO, forward to$stdin.readline(with keywords) instead of going throughgets. MRI'sargf_readlinedoes this withARGF_FORWARD. That's why CRuby doesn't count these lines inARGF.lineno, and why the forwardedreadlineraises its own EOFError.gets,readlineandreadlinesto$stdinnow usesNormalCachingCallSites inArgfSites, so only public methods are called. That matches MRI'sforward_current, which usesrb_funcallv_public_kw. Before, it usedcallMethod, which also reaches private methods. With a plain object as$stdin, that found the privateKernel#getsand friends and recursed.readlinesalready overflowed the stack this way onjruby-10.0. This is the second commit (b64041f).Not changed:
Kernel#readlineMRI's
rb_f_readlinecallsARGF.readline, so in CRuby a non-IO$stdingets$stdin.readlineand the line isn't counted. JRuby'sKernel#readlinecallsKernel#getsand raises EOFError onnil, so after this PR it calls$stdin.getsand 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#readlinethroughARGF.readline. I leftKernel#readlinealone because it's widely used. The proposed patch, its measured effects and its risk are in this comment for discussion.Testing
shared/gets.rb(ARGF.getsandARGF.readline): reading each line, and passing keywords on.gets_spec.rb:linenois incremented.readline_spec.rb:readlineis forwarded to$stdinwith the separator, limit and keywords (checked with a mock), and EOFError at the end of$stdin.gets_spec.rb,readline_spec.rbandreadlines_spec.rb: NoMethodError when$stdinhas no publicgets,readlineorreadlines.ARGF.class.newwith no arguments, which is stream mode. MRI only supports a non-IO$stdinin stream mode. With"-"in ARGV, MRI'snext_argvcallsGetOpenFileon it and a StringIO raisesIOError: uninitialized stream.jruby-10.0, all 11 new example runs fail, with aClassCastExceptionor (forreadlines) aStackOverflowError.spec/ruby/core/{io,file,enumerator,argf,kernel}andlibrary/stringiowithmspec ci: the same 2 failures as unmodifiedjruby-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 touchargf_getline's neighbors,ARGF.readline, andspec/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
RubyIOand fail with a non-IO$stdinonjruby-10.0. CRuby handles all of these:ARGF.readpartial(1),ARGF.read_nonblock(1)EOFError(forwarded)ClassCastExceptionARGF.to_ioNoMethodError(noStringIO#to_io)ClassCastExceptionARGF.filenonilClassCastExceptionARGF.pos,ARGF.rewind0ClassCastExceptionARGF.eof?,ARGF.eoffalsetrue(wrong, no exception)ARGF.binmode$stdinARGFI can fix these here, in #9784, or in a follow-up. Let me know which you'd prefer.
🤖 Generated with Claude Code