Repository navigation
Conversation
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
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 ( |
hasumikin
marked this pull request as ready for review
November 6, 2026 10:14
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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. UnlessReline.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::IOGateis a constant thatdecide_io_gatedecides at load time, andLineEditorreads the constantReline::IOGatedirectly. 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_widthlooks atSTDIN.tty?andSTDOUT.tty?.winsizeof the input or fromLINES/COLUMNS, and changes only onSIGWINCH. A socket has nowinsize.read_single_charand the cursor position query call@input.rawwithout a check. On a socket that raisesENOTTY.The modified Reline
Each of the four places is loosened.
Reline.io_gateandReline.io_gate=. A gate set withReline.io_gate=takes the place ofReline::IOGateforCoreandLineEditor.LineEditornow goes through theio_gatemethod instead of the constant (Corealready went through theio_gatemethod). Settingnilreturns to the constant.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 ofterminal?. Withterminal: nil(the default) it is the value ofboth_tty?, as before. The cursor query, bracketed paste and the ambiguous width check now look atterminal?instead ofboth_tty?.ANSI#set_screen_sizenow keeps the size, and when the input has nowinsize,get_screen_sizeanswers the kept size. AddReline.screen_size = [rows, columns], which sets the size and at the same time triggers the same redraw as afterSIGWINCH.LineEditor#interrupt.interruptsets the flag that theSIGINThandler sets. This is for a terminal that sends the interrupt not as a signal but as a byte (3 for Ctrl-C).Reline::Telnet. It is a class that makes a telnet connection the input ofReline::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.
A telnet client mixes control sequences (two or three bytes starting with
IAC0xFF, orIAC 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, andReline::Telnet.new(socket)does the following.getbyteand the like, it takes the control sequences out of the bytes of the socket and answers only the keys. It has the methods thatReline::ANSIcalls oninput(wait_readable,getbyte,readpartial,tty?,external_encoding).Reline.screen_size=. It passes Ctrl-C (the byte 3, orIAC IP) toReline.line_editor.interrupt. The destinations can be changed withon_resizeandon_interrupt.gateanswersReline::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::Telnetis not needed. TheIOof the serial port can be passed as it is toReline::ANSI.new(input: port, output: port, terminal: true).Tests
Four tests are added to
test/reline/test_ansi.rb: a gate withterminal: trueover a pair of pipes asks for the cursor position and reads the answer; a pipe without a terminal asks nothing;set_screen_sizeshows inget_screen_size;read_single_chardoes not raiseENOTTY. Two tests are added totest/reline/test_within_pipe.rb:readmultilinereturns a line over a gate set withReline.io_gate=(the screen size set withReline.screen_size=); an interrupt reported from another thread raisesInterrupt.test/reline/test_telnet.rbis 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, andreadmultilinethroughgate.