5

Playing around with non-blocking console input, using Python's selectors in combination with sys.stdin, there is something I do not understand:

Suppose I want to exit a loop when a user presses Enter, possibly after entering some other characters first.

If I do a blocking read, as follows, the process always finishes after the first linefeed \n it encounters, as expected, regardless of any preceding characters:

import sys

character = ''
while character != '\n':
    character = sys.stdin.read(1)

Now consider the following minimized example of a non-blocking read:

import sys
import selectors

selector = selectors.DefaultSelector()
selector.register(fileobj=sys.stdin, events=selectors.EVENT_READ)

character = ''
while character != '\n':
    for key, __ in selector.select(timeout=0):
        character = key.fileobj.read(1)

If I hit Enter as the first input, that generates a linefeed character, and the process finishes, as expected.

However, if I input some other characters first, followed by Enter, the process does not finish: I need to hit Enter again before it does.

Apparently this implementation only works if the linefeed is the first input.

There's probably a good reason for this, but I do not see it at the moment, and could not find any related questions.

Does this have to do with my non-blocking implementation, or is it a stdin buffer thing, or perhaps something to do with the console or terminal implementation?

(I am running this from a python 3.8 shell on ubuntu.)

djvg
  • 11,722
  • 5
  • 72
  • 103
  • 1
    See https://stackoverflow.com/a/43929760/51685 - I think the terminal mode is related here... – AKX Jan 02 '21 at 20:00
  • @AKX: Thank you! Setting the terminal to `cbreak` mode does indeed seem to work. – djvg Jan 02 '21 at 21:01
  • @AKX: I do still wonder why this only happens with the `selectors` implementation. – djvg Jan 04 '21 at 14:22
  • Would using the underlying binary `sys.stdin.buffer` make a difference there? – AKX Jan 04 '21 at 14:37
  • Note that the second `` (and any preceding input) is handled by the shell after the python process exits. – djvg Jan 08 '23 at 17:29
  • Some related questions: https://stackoverflow.com/q/55596557, https://stackoverflow.com/q/21791621, https://stackoverflow.com/q/2408560, https://stackoverflow.com/q/34067884, https://stackoverflow.com/q/34067884 – djvg Jan 08 '23 at 20:27

1 Answers1

0

sys.stdin is an instance of io.TextIOWrapper which in turn wraps an instance of io.BufferedReader.

Adding print(repr(character)) to the end of your code let's you see what is happening:

$ python foo.py 
# enter asd\n
'a'
# enter \n
's'
'd'
'\n'

When you first enter "asd\n", python read all of it to a buffer, but only returns the first character. Since the underlying stdin is empty now, the next call to selector.select() blocks again. When you enter another newline, select unblocks and the code continues to read characters from the buffer until the first "\n" is reached.

You can bypass the buffering by using os.read(key.fileobj.fileno(), 1). This gives the expected behavior (note that it returns bytes instead of strings):

$ python foo.py 
# enter asd\n
b'a'
b's'
b'd'
b'\n'

Edit: some context about cbreak mode

From man 3 cbreak:

Normally, the tty driver buffers typed characters until a newline or carriage return is typed. The cbreak routine disables line buffering and erase/kill character-processing (interrupt and flow control characters are unaffected), making characters typed by the user immediately available to the program. The nocbreak routine returns the terminal to normal (cooked) mode.

In cbreak mode, the characters are sent one at a time, so python has no chance to buffer the input:

$ python foo.py 
# enter a
'a'
# enter s
's'
# enter d
'd'
# enter \n
'\n'
tobib
  • 2,114
  • 4
  • 21
  • 35
  • Note that `selector.select()` does not block, because we explicitly set `timeout=0`. We are simply polling for "available for read" events. Also `os.read(key.fileobj.fileno(), 1)` can be simplified to `os.read(key.fd, 1)`. Unfortunately, this does not answer the actual question, viz. why the original example does not work as expected. – djvg Jan 08 '23 at 17:17
  • You are right about `timeout=0`, I missed that. The explanation still holds though: After the initial read, stdin is empty. Whether select blocks or returns an empty list has basically the same effect. – tobib Jan 09 '23 at 19:38
  • Sorry, but, IMHO the answer *describes* (expanding on comments by @AKX) but does not *explain*. For example: What do you actually mean by "stdin is empty"? Are you referring to the python File object or the underlying OS input stream? If "stdin is empty", as you say, how come the `read(1)` calls, after the second ``, do return the remaining characters (where do they come from)? Why doesn't `select` detect the remaining characters immediately after the first one is read? Why does `select` suddenly detect *all* of the remaining characters after the *second* ``, instead of just one? – djvg Jan 10 '23 at 09:48