Hey everyone, does anyone know why Received keystroke: ... is only being printed every second keystroke here?
# Function to read from /dev/tty and send keystrokes to a channel
def read_tty_and_send(channel : Channel(Char))
File.open("/dev/tty", "r") do |tty|
while char = tty.raw &.read_char
channel.send char
end
end
rescue ex
puts "Error reading from /dev/tty: #{ex.message}"
end
# Create a channel for Char
keystroke_channel = Channel(Char).new
# Start the tty reading in a separate fiber
spawn read_tty_and_send(keystroke_channel)
# Read from the channel
while char = keystroke_channel.receive
puts "Received keystroke: #{char}"
end
I got it now - the problem was that I was thinking in a parallel execution scenario. Turns out the behavior is different if you enable the multi-threading flag. Without the flag you need the Fiber.yield call - otherwise the receive fiber (main fiber in this case) is not called/handed-over until the (unbuffered but technically still buffered) buffer is full.
def read_tty_and_send(channel : Channel(Char))
File.open("/dev/tty", "r") do |tty|
while char = tty.raw &.read_char
channel.send(char)
Fiber.yield
end
end
Now without Fiber.yield and without MT a buffer size of 1 (unbuffered) will first write the char to the buffer and only every second keystroke will yield to the receiving fiber
The behavior is different with MT, of course, because real parallelization will switch between the fibers automatically.
Which makes sense, apparently one just needs to be very careful to not mix up concurrent and parallel execution environments when dealing with fibers and channels.
I was able to reproduce this.
For clarity, the behaviour is that after the first keystroke, there’s no output. But after the second keystroke, there are two lines of output. This continues: every n-1 and n keystroke are printed together on the n keystroke.
How would I ensure that after the first channel send the other fiber is resumed? I mean: On the first keystroke the channel is empty, meaning that channel send will return immediately, put the message into the buffer with size 1 and starts waiting for the next char. Only if the buffer is full, the handover happens. Is there any best-practice for a scenario like this?
Ah no, I got confused. Channel#send does not automatically resume a waiting fiber immediately. It just enqueues it, which means it runs the next time the scheduler has an opportunity to swap fibers.
Introducing this opportunity with Fiber.yield is probably correct then.
I’m wondering if it could make sense to resume a waiting fiber directly, though
The problem is that tty.raw(&.read_char) is blocking the whole thread until it can read a char, blocking other fibers. If it was waiting on IO, the other fibers would be given a chance to run.
This situation can also happen with MT: if you’re unlucky both fibers will be on the same thread.
I first thought it was cfmakeraw(3) that sets VMIN=1 and VTIME=0 (block until at least 1 byte is written) that made read(2) blocking but the problem was that File forces blocking to true (see original commit). It should be fine usually (though I wonder about reading a file that is being written to by another process ), except that you’re not reading a regular file, but an interactive character device (/dev/tty)!
IO::FileDescriptor takes care to detect this, as well as pipes and sockets, but File forces blocking to true, so the tty.raw(&.read_char) becomes a blocking call
Adding tty.blocking = true fixes the issue: #read_char won’t block anymore but trigger the event-loop, which will resume the receiving fiber that got enqueued by the channel. I’ll try to get a patch in stdlib for File to behave like IO::FileDescriptor.
Note: you’ll have issues with the receiving channel being resumed while the RAW mode has been activated on the TTY (puts adds \n not \r\n).
Is there a way to detect if it should be blocking or not, or should the tty opening simply use FileDescriptor directly? Because setting regular block device files to nonblocking is kinda weird?
@yxhuvud I created a PR where I expose a blocking parameter to File.new (and methods that use it) that defaults to true but can be set to nil (autodetect) or false (nonblocking). Since we expose File#blocking= it makes sense to expose it right to the constructor —and later we might be able to avoid blocking when opening fifo files.
IO::FileDescriptor wouldn’t be enough since we still have to open the file (we don’t have a fd but a pathname), and only File.new is capable of that.
So the example would become:
File.open("/dev/tty", "r", blocking: false) do |tty|
end