Repository navigation
IO#tty? silently returns true for redirected stdio in embedded runtimes when --add-opens are missing #9590
Description
Summary
Currently on POSIX (not sure about Windows), when java.io and sun.nio.ch are not opened, JRuby appears to assume that everything is a tty.
This causes some issues, particularly with Bundler on Java 21+ where there is specific handling of failures when tty? is true to actually do exit/System.exit(), e.g here. This then obscures the actual bundler error that caused it, due to the tty? mis-detection.
In an embedded JRuby runtime (e.g. ScriptingContainer as used by something like jruby-rack inside a servlet container), STDOUT.tty? / STDERR.tty? return true even when the process' stdio is redirected to a file or /dev/null, unless the JVM was started with:
--add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED
Both opens are required. No warning seems to be logged when the reflective path fails — JRuby silently falls back to "assume standard stdio is a tty" (although there may be reasons to avoid logging and putting more "stuff" on the output...)
The JRuby launcher passes these opens automatically (via bin/.jruby.module_opts), so plain CLI usage is unaffected. Embedding containers (Tomcat, Jetty, …) do not pass them. Tomcat's stock catalina.sh opens java.base/java.io (for its own code) but not java.base/sun.nio.ch, so JRuby webapps on Tomcat get the wrong answer by default.
Environment
- JRuby 10.0.6.0
- OpenJDK Temurin 21 and 26, macOS 15 (arm64)
- Observed in the wild under Apache Tomcat 11.0.24 via jruby-rack, with stdout/stderr redirected to
/dev/null(brew services / launchd)
Reproduction (plain JVM, no launcher — mirrors a servlet container)
JRUBY_JAR="$(mise where ruby@jruby-10.0)/lib/jruby.jar" # or $JRUBY_HOME
SNIPPET='sc = org.jruby.embed.ScriptingContainer.new(org.jruby.embed.LocalContextScope::THREADSAFE)
p sc.run_scriptlet("STDERR.tty?")'
java -cp "$JRUBY_JAR" org.jruby.Main -e "$SNIPPET" 2>/dev/null
# => true (WRONG - stderr NOT a terminal -> expected false)
java --add-opens java.base/java.io=ALL-UNNAMED --add-opens java.base/sun.nio.ch=ALL-UNNAMED \
-cp "$JRUBY_JAR" org.jruby.Main -e "$SNIPPET" 2>/dev/null
# => false (correct)Alternatively, you can reproduce with the jruby launcher, by temporarily editing or removing bin/.jruby.module_opts:
mv "$JRUBY_HOME/bin/.jruby.module_opts" "$JRUBY_HOME/bin/.jruby.module_opts.bak"
ruby -e "$SNIPPET" 2>/dev/null
mv "$JRUBY_HOME/bin/.jruby.module_opts.bak" "$JRUBY_HOME/bin/.jruby.module_opts"Claude Analysis
Note: the naive one-liner ruby -e 'p STDERR.tty?' 2>/dev/null does not reproduce, even with the opens stripped. The top-level CLI runtime wires stdio to native device channels with hardcoded filenos 0/1/2, so no reflection is needed. Only embedded runtimes wrap the System.out/System.err PrintStreams in channels whose real fd must be recovered reflectively — hence the ScriptingContainer (fresh runtime, LocalContextScope::THREADSAFE) in the repro. The default ScriptingContainer.new (singleton scope) also doesn't reproduce from the CLI, because it reuses the CLI's already-wired runtime.
Mechanism
- For an embedded runtime, stdio channels are built from the configured
System.out/System.errstreams; the real file descriptor is recovered via reflection (FilenoUtil.getFilenoUsingReflection→sun.nio.ch.*internals /java.io.FileDescriptor.fd). - Without the two opens,
setAccessiblefails,ChannelFD.realFilenostays-1, andIO#tty?cannot use nativeisatty(3). - The fallback (jnr-posix
JavaPOSIXpath) reports the standard descriptors as ttys unconditionally — even whenSystem.console()isnulland fd 1/2 point at/dev/null(verified:-Djruby.native.enabled=falseflips a redirectedSTDOUT.tty?fromfalsetotrue). - Nothing is logged, so the misdetection is invisible until something behaves differently on a "tty" — e.g. Bundler 2.7's
exitabove.
Suggestions
Claude thinks any of these might help:
- When the real fileno for a stdio stream is unknown, default
tty?to false rather than true — "unknown" is far more likely to be a redirected/embedded stream than an interactive terminal (CRuby also returns false when in doubt). - Log a warning (once) when stdio fd reflection fails due to module access, naming the required
--add-opens, as is already done for other JPMS-restricted paths.
Activity
headius commentedon Aug 17, 2026
Simplest option is probably to return false when the native FD cannot be accessed or
isattycannot be called, but other scenarios need to be considered.There's basically these variables controlling whether we can report TTY status:
isatty.If both of these capabilities are available, we can provide a "true" indication of TTYness.
If either of these capabilities is unavailable, there's one known pure-JDK fallback: if we know the stream is a "natural" JVM stdio channel, then
java.lang.System.console()should return non-null. There are some exceptions to this case, however, as shown by logic inorg.jruby.util.cli.Optionshere:jruby/core/src/main/java/org/jruby/util/cli/Options.java
Lines 63 to 82 in 10387d3
Note that the code in
Optionsis done statically, and is only used to indicate whether the entire JVM is connected to a TTY. For individual JRuby runtimes to make this determination, we also need to track that the Ruby stdio channels are derived from the JVM stdio channels.There are also cases where we may want to treat stdio as a TTY even without being able to make the determination, such as for users running the jruby-complete jar (or other command-line jar-based execution of JRuby).
I'll look over the current code and Claude recommendations and come back with something.
chadlwilson commentedon Aug 17, 2026
FWIW, in the above I didn't ask Claude to actually suggest fixes and go deeper into JRuby code, just to write up our collective analysis thus far. So its suggestions are made without context (I'm aware there are potential annoyances that come from JRuby logging stuff which CRuby normally wouldnt).
headius commentedon Aug 17, 2026
Native code in recent JDKs uses the following to determine TTY status for
System.console:There seems to be some confusion about whether
System.consolewill return a result for non-TTY. The documentation forConsole.isTerminalseems to claim it won't:I'll need to dig a bit to determine why we added the
isTerminalcall, since my quick test code works like the JDK documentation says it should:Output:
headius commentedon Aug 17, 2026
Ok, this does appear to be a bug in JDK 22. By JDK 25, the logic was corrected to only return a non-null
Consolewhen stdio is actually connected to a terminal.We generally do not support JRuby use on non-current non-LTS JDK releases, but we should keep the check in place for this odd JDK 22 behavior. I believe we can rely on
System.console()returning null for non-terminals in the future.headius commentedon Aug 17, 2026
Based on the following OpenJDK issue, it appears that JDK 22-24 defaulted to a JLine-based
Consolewhen stdio was not a terminal, requiring the additionalisTerminalcheck. JDK 25 switched back to the JDK 21 behavior of returning null, with JLine support returned to an opt-in propertyjdk.consolewithjdk.internal.leindicating the JLine version andjava.baseindicating the built-in pre-JDK22 behavior.https://bugs.openjdk.org/browse/JDK-8361911
I'm working to generalize the logic from our
Optionsclass to cover all possible scenarios. Once we can reliably tell if the JDK is connected to a TTY, we can make further checks for individual IO to provide a more accurateIO#tty?result.Use main-ness of JRuby to verify TTY status
Spec for tty? against non-terminal stdio streams
Centralize and improve System.console checking
Add additional spec for non-native non-terminal IO#tty?
Spec for tty? against non-terminal stdio streams