From 26a8222590902cddbcdee298cc01df96a59c5ae6 Mon Sep 17 00:00:00 2001 From: Shannon Skipper Date: Sun, 31 Aug 2025 22:41:53 -0700 Subject: [PATCH 1/2] Fix console cursor on BSDs in async context `IO.console.cursor` errors on kqueue-based systems when used with the fiber scheduler. ```ruby require 'async' Async { IO.console(:cursor) }.wait ``` This works on Linux but raises `Errno::EINVAL: Invalid argument` with `IO_Event_Selector_KQueue_Waiting_register` on macOS. I don't know if it's an acceptable fix, but this patch just dups file descriptors for stdin/stdout/stderr if detected alongside kqueue. --- ext/io/console/console.c | 28 +++++++++++++++++++++++++--- ext/io/console/extconf.rb | 1 + 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 3c8bb82..a3b1b25 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -96,6 +96,10 @@ extern VALUE rb_scheduler_timeout(struct timeval *timeout); # define rb_fiber_scheduler_make_timeout rb_scheduler_timeout #endif +#ifdef HAVE_SYS_EVENT_H +extern VALUE rb_fiber_scheduler_current(void); +#endif + #ifndef HAVE_RB_IO_DESCRIPTOR static int io_descriptor_fallback(VALUE io) @@ -1695,12 +1699,30 @@ console_dev(int argc, VALUE *argv, VALUE klass) int fd; VALUE path = rb_obj_freeze(rb_str_new2(CONSOLE_DEVICE)); + fd = -1; +#ifdef HAVE_SYS_EVENT_H + VALUE scheduler = rb_fiber_scheduler_current(); + if (!NIL_P(scheduler)) { + if (isatty(0)) { + fd = dup(0); + path = rb_obj_freeze(rb_str_new2("")); + } else if (isatty(1)) { + fd = dup(1); + path = rb_obj_freeze(rb_str_new2("")); + } else if (isatty(2)) { + fd = dup(2); + path = rb_obj_freeze(rb_str_new2("")); + } + } +#endif + if (fd == -1) { #ifdef CONSOLE_DEVICE_FOR_WRITING - fd = rb_cloexec_open(CONSOLE_DEVICE_FOR_WRITING, O_RDWR, 0); - if (fd < 0) return Qnil; - out = rb_io_open_descriptor(klass, fd, FMODE_WRITABLE | FMODE_SYNC, path, Qnil, NULL); + int writing_fd = rb_cloexec_open(CONSOLE_DEVICE_FOR_WRITING, O_RDWR, 0); + if (writing_fd < 0) return Qnil; + out = rb_io_open_descriptor(klass, writing_fd, FMODE_WRITABLE | FMODE_SYNC, path, Qnil, NULL); #endif fd = rb_cloexec_open(CONSOLE_DEVICE_FOR_READING, O_RDWR, 0); + } if (fd < 0) { #ifdef CONSOLE_DEVICE_FOR_WRITING rb_io_close(out); diff --git a/ext/io/console/extconf.rb b/ext/io/console/extconf.rb index dd3d221..e018c3b 100644 --- a/ext/io/console/extconf.rb +++ b/ext/io/console/extconf.rb @@ -41,6 +41,7 @@ case ok when true have_header("sys/ioctl.h") if hdr + have_header("sys/event.h") # rb_check_hash_type: 1.9.3 # rb_io_get_write_io: 1.9.1 # rb_cloexec_open: 2.0.0 From 5f0f331c7ea437b6a5d0897a8cfe44b7b8950891 Mon Sep 17 00:00:00 2001 From: Shannon Skipper Date: Sun, 31 Aug 2025 22:42:02 -0700 Subject: [PATCH 2/2] Add cursor position kqueue regression test --- Gemfile | 1 + test/io/console/test_io_console.rb | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 3b70e84..8788922 100644 --- a/Gemfile +++ b/Gemfile @@ -9,4 +9,5 @@ group :development do gem "test-unit" gem "test-unit-ruby-core" gem 'rake-compiler' + gem "async" end diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index c3f9c91..6e7c52d 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -169,9 +169,9 @@ def test_echo def test_noecho helper {|m, s| s.noecho { - assert_not_send([s, :echo?]) - m.print "a" - sleep 0.1 + assert_not_send([s, :echo?]) + m.print "a" + sleep 0.1 } m.print "b" assert_equal("b", m.readpartial(10)) @@ -367,6 +367,21 @@ def test_cursor_position end end + def test_cursor_position_kqueue_regression + run_pty("#{<<~"begin;"}\n#{<<~'end;'}") do |r, w, _| + begin; + require "async" + require "io/console" + + coords = Async { IO.console.cursor }.wait + p coords + end; + assert_equal("\e[6n", r.readpartial(5)) + w.print("\e[12;34R"); w.flush + assert_equal([11, 33], eval(r.gets)) + end + end + def assert_ctrl(expect, cc, r, w) sleep 0.1 w.print cc