G
YouTube TikTok Games
Skip to content

Run the line editor on a terminal that is not a tty of the processRemote terminal - #932

Open
hasumikin wants to merge 4 commits into
ruby:masterfrom
hasumikin:remote-terminal
Open

hasumikin wants to merge 4 commits into
ruby:masterfrom
hasumikin:remote-terminal

Conversation

@hasumikin

Copy link
Copy Markdown
Contributor

Background

I want to connect to a running Ruby program from outside and open IRB inside the program. For example, the program waits on a TCP port, and the user connects with telnet localhost PORT. The user has only a terminal emulator and the telnet command at hand, and no Ruby.

On the user's side there is a real terminal (it answers the cursor position query, reports the screen size, and sends keys one at a time). On the side of the Ruby program, on the other hand, there is a socket, which is not a tty. The current Reline decides whether a terminal is there by "whether the input and the output are ttys", so when it is given a socket it acts as if there were no terminal. It falls back to the behavior of reading lines from a pipe (no cursor query, the screen fixed at 24x80), and in some places it stops with ENOTTY.

The same happens with a terminal on the far side of a serial line (Ruby running on a single-board computer with no display, reached from a terminal emulator over the serial console).

The modified Reline only has new setters and a new class Reline::Telnet; the behavior for existing users (people who use irb in a terminal) does not change. Unless Reline.io_gate= (described below) is called, the gate is the constant decided at load time as before, and the tty checks are as before. The existing tests are unchanged.

Where the current Reline has the problem

The current Reline binds itself to the ttys of the process in four places.

  • Reline::IOGate is a constant that decide_io_gate decides at load time, and LineEditor reads the constant Reline::IOGate directly. There is no way to plug in a gate that uses other input and output.
  • ANSI#both_tty? decides whether to ask the terminal for the cursor position and whether to use bracketed paste. may_req_ambiguous_char_width looks at STDIN.tty? and STDOUT.tty?.
  • The screen size comes from winsize of the input or from LINES/COLUMNS, and changes only on SIGWINCH. A socket has no winsize.
  • read_single_char and the cursor position query call @input.raw without a check. On a socket that raises ENOTTY.

The modified Reline

Each of the four places is loosened.

  • Add Reline.io_gate and Reline.io_gate=. A gate set with Reline.io_gate= takes the place of Reline::IOGate for Core and LineEditor. LineEditor now goes through the io_gate method instead of the constant (Core already went through the io_gate method). Setting nil returns to the constant.
  • Add Reline::IO#terminal?. terminal? answers whether a terminal that answers queries is on the far side. ANSI.new(input:, output:, terminal:) takes the IO pair and the answer of terminal?. With terminal: nil (the default) it is the value of both_tty?, as before. The cursor query, bracketed paste and the ambiguous width check now look at terminal? instead of both_tty?.
  • ANSI#set_screen_size now keeps the size, and when the input has no winsize, get_screen_size answers the kept size. Add Reline.screen_size = [rows, columns], which sets the size and at the same time triggers the same redraw as after SIGWINCH.
  • Raw mode is set only when the input is a tty. This is gathered into one private helper method.
  • Add LineEditor#interrupt. interrupt sets the flag that the SIGINT handler sets. This is for a terminal that sends the interrupt not as a signal but as a byte (3 for Ctrl-C).
  • Add Reline::Telnet. It is a class that makes a telnet connection the input of Reline::ANSI; the details are in the next section.

How to use the modified Reline

For the telnet server of the Background, once a connection is accepted, the following two lines point Reline at the connection.

Reline.io_gate = Reline::Telnet.new(socket).gate
line = Reline.readmultiline("> ") { true }

A telnet client mixes control sequences (two or three bytes starting with IAC 0xFF, or IAC SB ... IAC SE) in between the keys the user types. The option negotiation right after the connection and the report of the screen size (NAWS) are control sequences. Control sequences are not keys, so they have to be taken out before they are passed to Reline. Reline::Telnet, added in the modified Reline, is what takes them out, and Reline::Telnet.new(socket) does the following.

  • It sends the negotiation right after the connection. Four things: the server echoes, keys are sent one at a time, the screen size is reported, and 8-bit data passes. Other options the client brings up are refused.
  • When read with getbyte and the like, it takes the control sequences out of the bytes of the socket and answers only the keys. It has the methods that Reline::ANSI calls on input (wait_readable, getbyte, readpartial, tty?, external_encoding).
  • It passes the report of the screen size to Reline.screen_size=. It passes Ctrl-C (the byte 3, or IAC IP) to Reline.line_editor.interrupt. The destinations can be changed with on_resize and on_interrupt.
  • gate answers Reline::ANSI.new(input: self, output: socket, terminal: true). output: is the socket itself because what the gate writes is ANSI escape sequences, which the terminal emulator interprets as they are.

feed(chunk) is a public method, for a host that reads the socket by itself (something like IRB, which wants to see Ctrl-C while it evaluates too) to get the keys out of the bytes it has read.

For a serial line there are no control sequences, so Reline::Telnet is not needed. The IO of the serial port can be passed as it is to Reline::ANSI.new(input: port, output: port, terminal: true).

Tests

Four tests are added to test/reline/test_ansi.rb: a gate with terminal: true over a pair of pipes asks for the cursor position and reads the answer; a pipe without a terminal asks nothing; set_screen_size shows in get_screen_size; read_single_char does not raise ENOTTY. Two tests are added to test/reline/test_within_pipe.rb: readmultiline returns a line over a gate set with Reline.io_gate= (the screen size set with Reline.screen_size=); an interrupt reported from another thread raises Interrupt. test/reline/test_telnet.rb is added (five tests). With a pair of pipes in place of a socket, it checks the negotiation sent right after the connection, the keys with the control sequences removed, the NAWS values, the answers to refused options, a sequence split across chunks, the end of the connection, and readmultiline through gate.

hasumikin and others added 3 commits November 6, 2026 15:05
A remote REPL (a console reached over a socket or a serial line) has a
real terminal on the far side, but the IO pair in the process is not a
tty. Reline bound itself to the process's ttys in four places; this
loosens each one, with no change for the default gate.

- Reline.io_gate= replaces the IO gate. LineEditor used the constant
  Reline::IOGate directly; it now goes through its io_gate method, as
  Core already did, so a gate set this way reaches every caller.
- Reline::IO#terminal? says whether a terminal that answers queries is
  on the far side. ANSI.new takes input:, output: and terminal:; with
  terminal: nil (the default) the answer is both_tty?, as before. The
  cursor position query, bracketed paste and the ambiguous width probe
  ask terminal? instead of the ttys of the process.
- ANSI#set_screen_size keeps the size, and get_screen_size answers it
  when the input has no winsize. Reline.screen_size = [rows, columns]
  sets it and makes the line editor redraw, as after SIGWINCH.
- The raw mode of the input is set only when the input is a tty, in one
  private helper; the far side of a socket is raw already, and a pipe has
  no mode. read_single_char and the cursor query raised ENOTTY before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpVYsAE2Eb17FkqeVd1gKE
On a tty Ctrl-C is SIGINT, and the handler of the line editor sets
@interrupted; the next read of a key drops the line and calls the handler
that was there before. A remote terminal sends a byte (3, or IAC IP over
telnet), not a signal. LineEditor#interrupt sets the same flag, so the
code that reads the connection can report the interrupt from any thread,
and the line editor handles it as after SIGINT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpVYsAE2Eb17FkqeVd1gKE
A telnet client mixes control sequences into the keys: the option
negotiation at the start and the report of the window size. Reline::Telnet
wraps the connection for Reline::ANSI: it negotiates (echo by the server,
one key at a time, the window size, 8-bit data), takes the sequences out
of the keys, gives the window size to Reline.screen_size= and Ctrl-C (the
byte 3, or IAC IP) to Reline.line_editor.interrupt. Its gate method
answers the ANSI gate on the connection, so a server does

  Reline.io_gate = Reline::Telnet.new(socket).gate

feed is public for a host that reads the socket itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpVYsAE2Eb17FkqeVd1gKE
Ruby 2.7 with -w warns "instance variable @io_gate not initialized" on
the first read of Reline.io_gate, and the tests of irb take the warning
as a failure. The module sets @io_gate to nil at load.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpVYsAE2Eb17FkqeVd1gKE
@hasumikin

Copy link
Copy Markdown
Contributor Author

About the failing irb 4.0 job: it is not related to this PR. test_context_mode_ruby_box fails with a crash of the Ruby 4.0.9 interpreter (irb/completion.rb:477: [BUG] Local ep without cme/box, a binding inside Ruby::Box). No Reline code is on the path. The same irb 4.0 job has been failing on the CI of reline's master since November 3 (for example, run 42046657200).

@hasumikin
hasumikin marked this pull request as ready for review November 6, 2026 10:14

This branch has not been deployed

No deployments
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.

1 participant