Skip to content

feat: Add /dev/tty fallback for terminals with piped streams#1728

Merged
gnodet merged 4 commits intomasterfrom
fix-1714-devtty-fallback
Apr 8, 2026
Merged

feat: Add /dev/tty fallback for terminals with piped streams#1728
gnodet merged 4 commits intomasterfrom
fix-1714-devtty-fallback

Conversation

@gnodet
Copy link
Copy Markdown
Member

@gnodet gnodet commented Mar 30, 2026

Summary

  • Adds opt-in /dev/tty fallback when no system stream is a TTY (e.g., when running via exec-maven-plugin's exec:exec goal)
  • New system property org.jline.terminal.devtty=true and builder method devTty(true) to enable the fallback
  • Uses existing TerminalProvider.newTerminal() with /dev/tty streams — FFM/JNI providers create a full PosixPtyTerminal, exec provider creates an ExternalTerminal
  • Saves/restores terminal settings via stty on open/close
  • Documents the behavior, the fix, and alternative workarounds in the troubleshooting guide and terminal docs

Fixes #1714

Summary by CodeRabbit

Release Notes

  • New Features

    • Added /dev/tty fallback support to maintain terminal functionality when input/output are piped (POSIX systems only).
  • Documentation

    • Updated configuration and troubleshooting guides with instructions for enabling the /dev/tty fallback option via system property or programmatic configuration.

gnodet added 3 commits March 30, 2026 09:32
…1714)

When a JLine application is launched by a process that redirects stdin/stdout
through pipes (e.g., Maven exec-maven-plugin with exec:exec goal), JLine
cannot detect a TTY and falls back to a dumb terminal, silently disabling
interactive features like tab completion, syntax highlighting, and history.

This adds an opt-in /dev/tty fallback that opens the controlling terminal
directly when no system stream is a TTY. Enable it with the system property
-Dorg.jline.terminal.devtty=true or the builder method devTty(true).

The implementation saves the current terminal settings, sets raw mode on
/dev/tty via stty, and uses TerminalProvider.newTerminal() with /dev/tty
streams. FFM/JNI providers create a full PosixPtyTerminal with native PTY
support; the exec provider creates an ExternalTerminal. Original terminal
settings are restored when the terminal is closed.
Instead of manually running stty commands and opening /dev/tty
streams in TerminalBuilder, add an overloaded sysTerminal() method
to TerminalProvider (without systemStream parameter) for creating
a terminal from the controlling terminal device.

ExecTerminalProvider implements this by creating an ExecPty with
"/dev/tty" as the device name, leveraging ExecPty's existing
support for named devices (stty -F /dev/tty for attributes,
FileInputStream/FileOutputStream for I/O). The resulting
PosixSysTerminal handles raw mode save/restore through the Pty
abstraction automatically.

Other providers (FFM, JNI) inherit the default implementation
which throws UnsupportedOperationException, allowing the builder
to try the next provider.
@gnodet
Copy link
Copy Markdown
Member Author

gnodet commented Mar 30, 2026

Closing this in favor of a split approach:

@gnodet gnodet closed this Mar 30, 2026
@gnodet gnodet reopened this Mar 30, 2026
@gnodet gnodet added this to the 4.1.0 milestone Mar 30, 2026
gnodet added a commit that referenced this pull request Mar 30, 2026
…lback

# Conflicts:
#	website/docs/terminal.md
#	website/docs/troubleshooting.md
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a /dev/tty fallback mechanism to JLine's terminal handling. When stdin and stdout are piped (preventing TTY detection), users can now optionally enable a fallback that attempts to connect directly to the controlling terminal device via /dev/tty on POSIX systems. The feature includes a new TerminalProvider API method, implementation support, builder configuration, and documentation.

Changes

Cohort / File(s) Summary
Terminal Provider SPI
terminal/src/main/java/org/jline/terminal/spi/TerminalProvider.java
Added new default sysTerminal(...) overload without SystemStream parameter to support controlling-terminal connections. Default implementation throws UnsupportedOperationException, allowing providers to optionally override for /dev/tty support.
Provider Implementation
terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java
Implemented new sysTerminal(...) overload that creates ExecPty targeting /dev/tty on POSIX systems; throws UnsupportedOperationException on Windows.
Terminal Builder
terminal/src/main/java/org/jline/terminal/TerminalBuilder.java
Added PROP_DEV_TTY constant, devTty configuration field, and fluent builder method. Extended doBuild() logic to attempt /dev/tty fallback when initial terminal creation fails (non-Windows, non-dumb environments).
Documentation
website/docs/terminal.md, website/docs/troubleshooting.md
Updated section titles and content to document the new /dev/tty fallback feature, including configuration examples via builder method and system property, and clarified input sourcing behavior.

Sequence Diagram(s)

sequenceDiagram
    participant App as Application
    participant Builder as TerminalBuilder
    participant Provider as TerminalProvider(s)
    participant ExecProvider as ExecTerminalProvider
    participant DevTTY as /dev/tty

    App->>Builder: build()
    Builder->>Builder: doBuild() - initial attempt
    Builder->>Builder: terminal == null?
    alt Initial terminal creation failed
        Builder->>Builder: Check devTty config
        Builder->>Builder: Iterate providers
        loop For each TerminalProvider
            Builder->>Provider: sysTerminal(...) [no SystemStream]
            alt ExecTerminalProvider implementation
                Provider->>ExecProvider: sysTerminal() override
                ExecProvider->>ExecProvider: Non-Windows check
                ExecProvider->>ExecProvider: Create ExecPty(/dev/tty)
                ExecProvider->>DevTTY: Connect to controlling terminal
                ExecProvider-->>Provider: Return PosixSysTerminal
            else Provider not implemented
                Provider-->>Builder: UnsupportedOperationException
            end
            Provider-->>Builder: Terminal or exception
        end
        alt Terminal acquired
            Builder->>Builder: Use fallback terminal
        end
    end
    Builder-->>App: Terminal instance
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 When pipes block the TTY's light,
A clever fallback hops into sight—
/dev/tty beckons, interactive and true,
Tab completion returns, completion breakthrough!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding a /dev/tty fallback feature for terminals with piped streams, which is the primary objective.
Linked Issues check ✅ Passed The PR fully addresses issue #1714 by implementing an opt-in /dev/tty fallback (via system property and builder API) to restore interactive features when stdin/stdout are piped.
Out of Scope Changes check ✅ Passed All changes are in-scope: new TerminalBuilder config/constant, TerminalProvider API addition, ExecTerminalProvider implementation, and documentation updates directly support the /dev/tty fallback feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-1714-devtty-fallback

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 2, 2026

@gnodet gnodet marked this pull request as ready for review April 2, 2026 10:18
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java`:
- Around line 134-152: The sysTerminal method in ExecTerminalProvider ignores
the passed paused parameter; after constructing the PosixSysTerminal instance in
sysTerminal(String name, ..., boolean paused) call terminal.pause() when paused
is true so the terminal starts paused as the TerminalProvider contract requires;
locate the sysTerminal method in ExecTerminalProvider, instantiate
PosixSysTerminal as currently done, then if (paused) invoke the terminal's
pause() method before returning the terminal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5144bf5-d41c-426c-9259-9a44d309f338

📥 Commits

Reviewing files that changed from the base of the PR and between cc8d538 and 805bd58.

📒 Files selected for processing (5)
  • terminal/src/main/java/org/jline/terminal/TerminalBuilder.java
  • terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java
  • terminal/src/main/java/org/jline/terminal/spi/TerminalProvider.java
  • website/docs/terminal.md
  • website/docs/troubleshooting.md

Comment on lines +134 to +152
@Override
public Terminal sysTerminal(
String name,
String type,
boolean ansiPassThrough,
Charset encoding,
Charset inputEncoding,
Charset outputEncoding,
boolean nativeSignals,
Terminal.SignalHandler signalHandler,
boolean paused)
throws IOException {
if (OSUtils.IS_WINDOWS) {
throw new UnsupportedOperationException("/dev/tty is not available on Windows");
}
Pty pty = new ExecPty(this, null, "/dev/tty");
return new PosixSysTerminal(
name, type, pty, encoding, inputEncoding, outputEncoding, nativeSignals, signalHandler);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for how PosixSysTerminal handles the paused parameter in other providers
ast-grep --pattern 'new PosixSysTerminal($$$)'

Repository: jline/jline3

Length of output: 2364


🏁 Script executed:

#!/bin/bash
# Find PosixSysTerminal class definition
fd -t f 'PosixSysTerminal.java' | head -5

Repository: jline/jline3

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check the PosixSysTerminal constructor signature
fd -t f 'PosixSysTerminal.java' -x cat {} \; | head -80

Repository: jline/jline3

Length of output: 2792


🏁 Script executed:

#!/bin/bash
# Check how paused parameter is handled in other sysTerminal implementations in ExecTerminalProvider
rg -A 20 "public Terminal sysTerminal" terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java | head -100

Repository: jline/jline3

Length of output: 1478


🏁 Script executed:

#!/bin/bash
# Get the full PosixSysTerminal class to see all constructor signatures
wc -l terminal/src/main/java/org/jline/terminal/impl/PosixSysTerminal.java

Repository: jline/jline3

Length of output: 128


🏁 Script executed:

#!/bin/bash
# Check PosixSysTerminal constructors
sed -n '60,150p' terminal/src/main/java/org/jline/terminal/impl/PosixSysTerminal.java

Repository: jline/jline3

Length of output: 3374


🏁 Script executed:

#!/bin/bash
# Search for pause() method in Terminal interface and related classes
rg -i "pause\(\)" --type java | head -20

Repository: jline/jline3

Length of output: 1625


🏁 Script executed:

#!/bin/bash
# Check the sysTerminal interface definition to understand the paused parameter contract
rg -B 5 -A 15 "Terminal sysTerminal.*boolean paused" terminal/src/main/java/org/jline/terminal/spi/TerminalProvider.java | head -60

Repository: jline/jline3

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check TerminalProvider.java for the interface definition of sysTerminal
fd -t f 'TerminalProvider.java'

Repository: jline/jline3

Length of output: 434


🏁 Script executed:

#!/bin/bash
# Get the full TerminalProvider interface
cat terminal-spi/src/main/java/org/jline/terminal/spi/TerminalProvider.java | head -200

Repository: jline/jline3

Length of output: 159


🏁 Script executed:

#!/bin/bash
# Check how JniTerminalProvider handles the paused parameter
rg -B 5 -A 25 "public Terminal sysTerminal" terminal-jni/src/main/java/org/jline/terminal/impl/jni/JniTerminalProvider.java | head -100

Repository: jline/jline3

Length of output: 988


🏁 Script executed:

#!/bin/bash
# Find the TerminalProvider in the correct path
find . -name 'TerminalProvider.java' -type f

Repository: jline/jline3

Length of output: 125


🏁 Script executed:

#!/bin/bash
# Check the content of the TerminalProvider interface
cat terminal/src/main/java/org/jline/terminal/spi/TerminalProvider.java | head -150

Repository: jline/jline3

Length of output: 5850


🏁 Script executed:

#!/bin/bash
# Check how FfmTerminalProvider handles the paused parameter in sysTerminal
rg -B 3 -A 20 "public Terminal sysTerminal" terminal-ffm/src/main/java/org/jline/terminal/impl/ffm/FfmTerminalProvider.java

Repository: jline/jline3

Length of output: 786


🏁 Script executed:

#!/bin/bash
# Check if PosixSysTerminal is supposed to receive paused parameter - look at AbstractPosixTerminal
rg -i "paused" terminal/src/main/java/org/jline/terminal/impl/AbstractPosixTerminal.java

Repository: jline/jline3

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Look for where paused is actually used in sysTerminal implementations
rg -B 3 -A 10 "boolean paused" terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java | head -60

Repository: jline/jline3

Length of output: 2209


🏁 Script executed:

#!/bin/bash
# Check if winSysTerminal or other terminal creation methods use paused
rg -B 2 -A 8 "if.*paused" terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java

Repository: jline/jline3

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check DumbTerminalProvider to see if it handles paused
rg -B 3 -A 15 "public Terminal sysTerminal" terminal/src/main/java/org/jline/terminal/impl/DumbTerminalProvider.java

Repository: jline/jline3

Length of output: 798


🏁 Script executed:

#!/bin/bash
# Check winSysTerminal method to see if it uses paused parameter
rg -B 5 -A 20 "private Terminal winSysTerminal" terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java | head -80

Repository: jline/jline3

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check if any terminal implementation in ExecTerminalProvider actually calls pause() after creating terminal
rg -B 2 -A 8 "terminal.pause" terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java

Repository: jline/jline3

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Look at how the paused parameter is used in JniTerminalProvider (if at all)
rg "boolean paused" terminal-jni/src/main/java/org/jline/terminal/impl/jni/JniTerminalProvider.java -A 15 | head -80

Repository: jline/jline3

Length of output: 2470


🏁 Script executed:

#!/bin/bash
# Verify the 9-parameter sysTerminal is indeed the default method in TerminalProvider interface
rg -B 10 "default Terminal sysTerminal" terminal/src/main/java/org/jline/terminal/spi/TerminalProvider.java

Repository: jline/jline3

Length of output: 697


🏁 Script executed:

#!/bin/bash
# Check if there are any usages of this 9-parameter sysTerminal in callers to understand expectations
rg "\.sysTerminal\(" terminal/src/main/java/org/jline/terminal/TerminalBuilder.java | head -10

Repository: jline/jline3

Length of output: 211


🏁 Script executed:

#!/bin/bash
# Final check: verify AbstractTerminal.pause() default implementation
rg -B 3 -A 5 "public void pause" terminal/src/main/java/org/jline/terminal/impl/AbstractTerminal.java

Repository: jline/jline3

Length of output: 243


🏁 Script executed:

#!/bin/bash
# Confirm PosixSysTerminal extends AbstractPosixTerminal which extends AbstractTerminal
head -5 terminal/src/main/java/org/jline/terminal/impl/PosixSysTerminal.java | tail -3

Repository: jline/jline3

Length of output: 201


The paused parameter is accepted but not used, violating the TerminalProvider interface contract.

The paused parameter at line 144 is declared but never passed to or acted upon when creating the PosixSysTerminal. The TerminalProvider interface explicitly documents this parameter as determining whether the terminal should start in a paused state. While the 10-parameter sysTerminal overload correctly passes paused to native methods on Windows, this 9-parameter variant (used for the /dev/tty fallback on POSIX systems) ignores it entirely, creating an inconsistency.

Since PosixSysTerminal inherits the pause() method from AbstractTerminal, the fix is straightforward: call terminal.pause() after construction if the paused parameter is true.

🔧 Proposed fix
     `@Override`
     public Terminal sysTerminal(
             String name,
             String type,
             boolean ansiPassThrough,
             Charset encoding,
             Charset inputEncoding,
             Charset outputEncoding,
             boolean nativeSignals,
             Terminal.SignalHandler signalHandler,
             boolean paused)
             throws IOException {
         if (OSUtils.IS_WINDOWS) {
             throw new UnsupportedOperationException("/dev/tty is not available on Windows");
         }
         Pty pty = new ExecPty(this, null, "/dev/tty");
-        return new PosixSysTerminal(
-                name, type, pty, encoding, inputEncoding, outputEncoding, nativeSignals, signalHandler);
+        PosixSysTerminal terminal = new PosixSysTerminal(
+                name, type, pty, encoding, inputEncoding, outputEncoding, nativeSignals, signalHandler);
+        if (paused) {
+            terminal.pause();
+        }
+        return terminal;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@terminal/src/main/java/org/jline/terminal/impl/exec/ExecTerminalProvider.java`
around lines 134 - 152, The sysTerminal method in ExecTerminalProvider ignores
the passed paused parameter; after constructing the PosixSysTerminal instance in
sysTerminal(String name, ..., boolean paused) call terminal.pause() when paused
is true so the terminal starts paused as the TerminalProvider contract requires;
locate the sysTerminal method in ExecTerminalProvider, instantiate
PosixSysTerminal as currently done, then if (paused) invoke the terminal's
pause() method before returning the terminal.

@gnodet gnodet merged commit 14e1666 into master Apr 8, 2026
11 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.

Tab completion, syntax highlighting, history and other features don't work when exec-maven-plugin is used with exec:exec goal

1 participant