ruby / reline

The compatible library with the API of Ruby's stdlib 'readline'
Other
265 stars 85 forks source link

Add a timeout to `cursor_pos` and warn if terminal does not respond to cursor position query #674

Closed tompng closed 1 month ago

tompng commented 7 months ago

Description

If terminal emulator does not respond to cursor position query "\e[6n", Reline will wait forever with displayed in terminal. Reline can add a timeout to at least work in such case. Warning message will help debugging.

Example:

Terminal Emulator

Pseudo terminal launched by PTY.spawn

etiennebarrie commented 7 months ago

Ah I was about to create an issue for this. Here's a repro script:

if ARGV.first == "prompt"
  require "readline"

  puts Readline.name

  puts Readline.readline("Prompt: ").upcase

else
  require "pty"

  output = ""
  PTY.spawn("ruby", __FILE__, "prompt") do |read, write, pid|
    loop do
      output += read.readpartial(4096)
      $stderr.puts(output.inspect)
      $stderr.puts(output)
      write.puts("hello") if output.end_with?(": ")
    rescue EOFError, SystemCallError
      Process.waitpid(pid)
      break
    end
  end
  puts
  puts "Output:", output
end

You can test this with a real terminal (podman can be replaced by docker):

$ podman run --rm -ti -v $PWD:/app ruby:3.2 ruby /app/bug.rb prompt
Readline
Prompt: readline
READLINE
$ podman run --rm -ti -v $PWD:/app ruby:3.3 ruby /app/bug.rb prompt
Reline
Prompt: reline
RELINE

But if we use PTY.spawn, it waits forever:

$ podman run --rm -ti -v $PWD:/app ruby:3.2 ruby /app/bug.rb 
"Readline\r\n"
Readline
"Readline\r\n\e[?2004hPrompt: "
Readline
Prompt: 
"Readline\r\n\e[?2004hPrompt: hello\r\n\e[?2004l\rHELLO\r\n"
Readline
Prompt: hello
HELLO

Output:
Readline
Prompt: hello
HELLO
$ podman run --rm -ti -v $PWD:/app ruby:3.3 ruby /app/bug.rb 
"Reline\r\n"
Reline
"Reline\r\n\e[1G\xE2\x96\xBD\e[6n"
Reline
▽
^[[37;2R^C/app/bug.rb:14:in `readpartial': Interrupt
    from /app/bug.rb:14:in `block (2 levels) in <main>'
    from <internal:kernel>:187:in `loop'
    from /app/bug.rb:13:in `block in <main>'
    from /app/bug.rb:12:in `spawn'
    from /app/bug.rb:12:in `<main>'

As you mentioned, we can see "\e[6n", then we can even see the result from the terminal ^[[37;2R, meaning line 37, col 2, but it's not read (readable?) by Reline.

Using TERM=dumb shows a different bug where the output is written multiple times, but at least it works:

$ podman run --rm -ti -v $PWD:/app -e TERM=dumb ruby:3.3 ruby /app/bug.rb 
"Reline\r\n"
Reline
"Reline\r\nPrompt: Prompt: "
Reline
Prompt: Prompt: 
"Reline\r\nPrompt: Prompt: hello\r\n"
Reline
Prompt: Prompt: hello
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: h"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: h
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: hel"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: hel
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hell"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hell
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: hello"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: hello
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: helloPrompt: hello"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: helloPrompt: hello
"Reline\r\nPrompt: Prompt: hello\r\nPrompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: helloPrompt: helloHELLO\r\n"
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: helloPrompt: helloHELLO

Output:
Reline
Prompt: Prompt: hello
Prompt: hPrompt: hPrompt: hePrompt: hePrompt: helPrompt: helPrompt: hellPrompt: hellPrompt: helloPrompt: helloHELLO

@tompng Do you know if there's a way to work around this without using TERM=dumb? I don't really understand how terminals work deeply, is this a bug from PTY.spawn? Is there a way to get PTY.spawn to write to the PTY what Reline expects at the other end of the device?

tompng commented 7 months ago

In my understanding, using PTY means that the program is expected to act like a terminal emulator.

PTY.spawn("ruby", __FILE__, "prompt") do |read, write, pid|
  # Spawned process can check if it is executed in terminal emulator by `IO#tty?`. When spawned by PTY, it returns true.
  loop do
    s = read.readpartial(4096)
    if s.include?("\e[6n")
      # If terminal emulator(==this program) receives "\e[6n", terminal emulator should report cursor position
      # Since this program is not a full terminal emulator, report dummy cursor position
      cursor_col, cursor_row = 1, 1
      # Or you can report real cursor position retrieved from current terminal, virtual terminal library, another process, from remote, whatever. PTY doesn't support this part.
      write.write "\e[#{cursor_row};#{cursor_col}R"
    end
    output += s
  end
end

This is too hard, so there is TERM=dumb to avoid it. If you don't need to provide tty to the spawned process, you can use IO.popen(command, 'r+') instead.