From d43917b10a27b668ad346f1ecfb197c3a46d6976 Mon Sep 17 00:00:00 2001 From: Kazuhiro NISHIYAMA Date: Sat, 8 Aug 2026 09:33:27 +0900 Subject: [PATCH 01/12] Omit Proc#syntax_tree binary round-trip test when coverage is enabled RubyVM::InstructionSequence#to_binary raises "should not compile with coverage" while coverage measurement is enabled. The test added by 6e65742a9f (Proc#syntax_tree) calls to_binary directly, which has made `make check COVERAGE=true` (the ruby/actions coverage workflow) fail on every run since it went green after 3dc6bdfd94. Guard the call with the same rescue/omit idiom used in test_iseq.rb and test_iseq_load.rb. Co-Authored-By: Claude Fable 5 --- test/ruby/test_proc_syntax_tree.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/ruby/test_proc_syntax_tree.rb b/test/ruby/test_proc_syntax_tree.rb index d6f25f04e4a281..4c4aaebee6f628 100644 --- a/test/ruby/test_proc_syntax_tree.rb +++ b/test/ruby/test_proc_syntax_tree.rb @@ -85,7 +85,12 @@ def test_returns_nil_for_c_method def test_source_hash_survives_binary_round_trip with_loaded_file("def proc_ast_test_binary = :ok\n") do |path| iseq = RubyVM::InstructionSequence.compile_file(path) - loaded = RubyVM::InstructionSequence.load_from_binary(iseq.to_binary) + loaded = begin + RubyVM::InstructionSequence.load_from_binary(iseq.to_binary) + rescue RuntimeError => e + omit e.message if /compile with coverage/ =~ e.message + raise + end assert_equal iseq.source_hash, loaded.source_hash assert_equal (PRISM ? :program_node : :SCOPE), loaded.syntax_tree.type From c7fae2f6513e7fe6afea05ac4e9794fb21e1d49a Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sat, 8 Aug 2026 00:34:55 +0000 Subject: [PATCH 02/12] GC: record a profile entry for a global collection gc_start_global never set up a profile record, so once a process has a second objspace and GC.start runs a global collection, GC::Profiler records nothing: raw_data comes back empty and raw_data.last is nil. TestGc#test_profiler_raw_data_includes_wall_time fails that way whenever a test-all worker has already run something that makes a Ractor, which is why it shows up as a flake -- it depends on how the suite is split across workers. What a collection records about itself before it runs was written out in gc_start_body; extract it as gc_start_record and let both paths use it, so a field added later reaches the global collection too. It covers the record itself, what triggered the collection (latest_gc_info, which had been left reporting the previous local one), the heap figures the record is built from at the end, and gc_reset_malloc_info -- which fills the record's malloc figures and resets the malloc trigger, since a global collection is a collection and what accumulated before it must not count towards the next one. A global collection's record now matches what a local one produces. The added test fails without the fix and does not depend on test ordering. Co-Authored-By: Claude Opus 5 (1M context) --- gc/default/default.c | 36 ++++++++++++++++++++++++------------ test/ruby/test_gc.rb | 19 +++++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index 441c118c5bd9c2..ce12a29ad38af6 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -7913,7 +7913,21 @@ gc_reset_malloc_info(rb_objspace_t *objspace, bool full_mark) #endif } -static void gc_start_global(rb_objspace_t *driver, bool compact); +/* What a collection records about itself before it runs. A global collection reports the + * driver's objspace, so it comes through here too. */ +static void +gc_start_record(rb_objspace_t *objspace, unsigned int reason, bool full_mark) +{ + objspace->profile.latest_gc_info = reason; + objspace->profile.total_allocated_objects_at_gc_start = total_allocated_objects(objspace); + objspace->profile.heap_used_at_gc_start = rb_darray_size(objspace->heap_pages.sorted); + objspace->profile.heap_total_slots_at_gc_start = objspace_available_slots(objspace); + objspace->profile.weak_references_count = 0; + gc_prof_setup_new_record(objspace, reason); + gc_reset_malloc_info(objspace, full_mark); +} + +static void gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact); /* Decide whether this collection has to be global. A local GC can reclaim neither * shareable objects nor zombie objspaces, so once those grow past their limits only a @@ -7967,7 +7981,7 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) * (only a global cycle reclaims dead shareable objects and zombie pages). The * exception is the retire GC, which never promotes: a Ractor's death must not STW. */ if (allow_global && gc_need_global_p(objspace)) { - gc_start_global(objspace, false); + gc_start_global(objspace, reason, false); return TRUE; } @@ -8072,13 +8086,7 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) } objspace->profile.count++; - objspace->profile.latest_gc_info = reason; - objspace->profile.total_allocated_objects_at_gc_start = total_allocated_objects(objspace); - objspace->profile.heap_used_at_gc_start = rb_darray_size(objspace->heap_pages.sorted); - objspace->profile.heap_total_slots_at_gc_start = objspace_available_slots(objspace); - objspace->profile.weak_references_count = 0; - gc_prof_setup_new_record(objspace, reason); - gc_reset_malloc_info(objspace, do_full_mark); + gc_start_record(objspace, reason, do_full_mark); gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_START); @@ -8683,15 +8691,18 @@ gc_global_mark_generic_fields(rb_objspace_t *driver) /* Two Ractors choosing a global GC at once are serialized by the barrier in gc_enter and * simply run two cycles back to back. The second is wasted work, not an error. */ static void -gc_start_global(rb_objspace_t *driver, bool compact) +gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact) { unsigned int lock_lev; gc_enter(driver, gc_enter_event_global, &lock_lev); /* A global GC is a collection of the driver's objspace too, and its profile.count * below says so, so report it like a local one. The driver is the objspace whose - * count moves, which is the one a hook reading GC.stat would compare against. */ + * count moves, which is the one a hook reading GC.stat would compare against. For + * the same reason it records a profile entry and reports what triggered it. */ + gc_start_record(driver, reason, true); gc_event_hook(driver, RUBY_INTERNAL_EVENT_GC_START); + gc_prof_timer_start(driver); GC_ASSERT(is_mark_stack_empty(&driver->mark_stack)); @@ -8915,6 +8926,7 @@ gc_start_global(rb_objspace_t *driver, bool compact) * zombie_objspaces entry and posted the merge to main as a postponed job; the objspace * stays enumerable until main absorbs it at its next safepoint. */ + gc_prof_timer_stop(driver); gc_exit(driver, gc_enter_event_global, &lock_lev); } @@ -9182,7 +9194,7 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i * collector that reclaims shareable and cross-objspace garbage. It stops the world, * so auto_compact is honoured here too (mirroring full mark x autocompact locally). */ if (!rb_gc_single_objspace_p() && (reason & GPR_FLAG_FULL_MARK)) { - gc_start_global(objspace, compact || ruby_enable_autocompact); + gc_start_global(objspace, reason, compact || ruby_enable_autocompact); } else { garbage_collect(objspace, reason); diff --git a/test/ruby/test_gc.rb b/test/ruby/test_gc.rb index 6874bc42987fb6..bbdfd3ca3e1cdc 100644 --- a/test/ruby/test_gc.rb +++ b/test/ruby/test_gc.rb @@ -619,6 +619,25 @@ def test_profiler_raw_data_since RUBY end + def test_profiler_raw_data_with_another_ractor + # A second objspace sends GC.start through the global collector, which has to record + # a profile entry the same way a local collection does. + assert_separately([], <<~RUBY) + Warning[:experimental] = false + Ractor.new {}.value + + GC::Profiler.enable + GC::Profiler.clear + GC.start + + record = GC::Profiler.raw_data.last + assert_not_nil record + assert_kind_of Float, record[:GC_WALL_TIME] + RUBY + ensure + GC::Profiler.disable + end + def test_profiler_raw_data_includes_wall_time auto_compact = GC.auto_compact if GC.respond_to?(:auto_compact) GC.auto_compact = false if GC.respond_to?(:auto_compact=) From 7f3171f0482ef457bb653c37143aeb8da6d203b5 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sat, 8 Aug 2026 00:59:44 +0000 Subject: [PATCH 03/12] GC: stop maintaining profile fields nothing reads Of the four figures a collection snapshots about itself, three are read only by gc_prof_set_heap_info inside #if GC_PROFILE_MORE_DETAIL, and one of those -- heap_total_slots_at_gc_start -- has no reader at all. A default build walks every heap twice per collection to fill fields it will never look at. Put the two the detailed profiler wants behind the same #if, and drop the third. Co-Authored-By: Claude Opus 5 (1M context) --- gc/default/default.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index ce12a29ad38af6..8ba21da386d38b 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -697,9 +697,10 @@ typedef struct rb_objspace { rb_hrtime_t gc_stop_time; rb_hrtime_t gc_mark_phase_wall_start_time; rb_hrtime_t gc_sweep_phase_wall_start_time; +#if GC_PROFILE_MORE_DETAIL size_t total_allocated_objects_at_gc_start; size_t heap_used_at_gc_start; - size_t heap_total_slots_at_gc_start; +#endif /* basic statistics */ size_t count; @@ -7919,9 +7920,10 @@ static void gc_start_record(rb_objspace_t *objspace, unsigned int reason, bool full_mark) { objspace->profile.latest_gc_info = reason; +#if GC_PROFILE_MORE_DETAIL objspace->profile.total_allocated_objects_at_gc_start = total_allocated_objects(objspace); objspace->profile.heap_used_at_gc_start = rb_darray_size(objspace->heap_pages.sorted); - objspace->profile.heap_total_slots_at_gc_start = objspace_available_slots(objspace); +#endif objspace->profile.weak_references_count = 0; gc_prof_setup_new_record(objspace, reason); gc_reset_malloc_info(objspace, full_mark); From 937f6c22b302cdcbb062bf9ab916c957ffc6ab49 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sat, 8 Aug 2026 14:46:58 +0900 Subject: [PATCH 04/12] Use `assert_include` instead of `assert_includes` --- test/fiber/test_mutex.rb | 2 +- test/objspace/test_objspace.rb | 12 ++++++------ test/ruby/test_box.rb | 8 ++++---- test/ruby/test_iseq.rb | 2 +- test/ruby/test_object.rb | 6 +++--- test/ruby/test_process.rb | 2 +- test/ruby/test_set.rb | 2 +- test/ruby/test_settracefunc.rb | 6 +++--- test/ruby/test_string.rb | 10 +++++----- test/ruby/test_yjit.rb | 6 +++--- test/ruby/test_zjit_cli.rb | 8 ++++---- test/socket/test_socket.rb | 2 +- tool/test/test_sync_default_gems.rb | 2 +- 13 files changed, 34 insertions(+), 34 deletions(-) diff --git a/test/fiber/test_mutex.rb b/test/fiber/test_mutex.rb index 2cee2cc235684b..b2221dc9447c3d 100644 --- a/test/fiber/test_mutex.rb +++ b/test/fiber/test_mutex.rb @@ -232,7 +232,7 @@ def test_mutex_fiber_deadlock_no_scheduler mutex.lock end.resume end - assert_includes error.message, "deadlock; lock already owned by another fiber belonging to the same thread" + assert_include error.message, "deadlock; lock already owned by another fiber belonging to the same thread" end ensure thr&.kill&.join diff --git a/test/objspace/test_objspace.rb b/test/objspace/test_objspace.rb index 2378aaf4016b55..e48f1f60fa61c2 100644 --- a/test/objspace/test_objspace.rb +++ b/test/objspace/test_objspace.rb @@ -687,18 +687,18 @@ def bar end; assert_empty error assert(output.count > 1) - assert_includes output.grep(/"imemo_type":"callinfo"/).join("\n"), '"mid":"baz"' + assert_include output.grep(/"imemo_type":"callinfo"/).join("\n"), '"mid":"baz"' end end def test_dump_string_coderange - assert_includes ObjectSpace.dump("TEST STRING"), '"coderange":"7bit"' + assert_include ObjectSpace.dump("TEST STRING"), '"coderange":"7bit"' unknown = "TEST STRING".dup.force_encoding(Encoding::UTF_16BE) 2.times do # ensure that dumping the string doesn't mutate it - assert_includes ObjectSpace.dump(unknown), '"coderange":"unknown"' + assert_include ObjectSpace.dump(unknown), '"coderange":"unknown"' end - assert_includes ObjectSpace.dump("Fée"), '"coderange":"valid"' - assert_includes ObjectSpace.dump("\xFF"), '"coderange":"broken"' + assert_include ObjectSpace.dump("Fée"), '"coderange":"valid"' + assert_include ObjectSpace.dump("\xFF"), '"coderange":"broken"' end def test_dump_escapes_method_name @@ -711,7 +711,7 @@ def test_dump_escapes_method_name obj = klass.new.send(method_name) dump = ObjectSpace.dump(obj) - assert_includes dump, '"method":"foo\"bar"' + assert_include dump, '"method":"foo\"bar"' parsed = JSON.parse(dump) assert_equal "foo\"bar", parsed["method"] diff --git a/test/ruby/test_box.rb b/test/ruby/test_box.rb index c60fc390cef6f7..199903505760df 100644 --- a/test/ruby/test_box.rb +++ b/test/ruby/test_box.rb @@ -891,10 +891,10 @@ def test_prelude_gems_and_loaded_features assert_match EXPERIMENTAL_WARNING_LINE_PATTERNS[0], error[0] assert_match EXPERIMENTAL_WARNING_LINE_PATTERNS[1], error[1] - assert_includes output.grep(/^before:/).join("\n"), '/bundled_gems.rb' + assert_include output.grep(/^before:/).join("\n"), '/bundled_gems.rb' refute_includes output.grep(/^before:/).join("\n"), '/error_highlight.rb' - assert_includes output.grep(/^after:/).join("\n"), '/bundled_gems.rb' - assert_includes output.grep(/^after:/).join("\n"), '/error_highlight.rb' + assert_include output.grep(/^after:/).join("\n"), '/bundled_gems.rb' + assert_include output.grep(/^after:/).join("\n"), '/error_highlight.rb' end end @@ -917,7 +917,7 @@ def test_prelude_gems_and_loaded_features_with_disable_gems refute_includes output.grep(/^before:/).join("\n"), '/bundled_gems.rb' refute_includes output.grep(/^before:/).join("\n"), '/error_highlight.rb' refute_includes output.grep(/^after:/).join("\n"), '/bundled_gems.rb' - assert_includes output.grep(/^after:/).join("\n"), '/error_highlight.rb' + assert_include output.grep(/^after:/).join("\n"), '/error_highlight.rb' end end diff --git a/test/ruby/test_iseq.rb b/test/ruby/test_iseq.rb index 9d7001fa61067d..9d946c8e8efd69 100644 --- a/test/ruby/test_iseq.rb +++ b/test/ruby/test_iseq.rb @@ -177,7 +177,7 @@ def test_ractor_shareable_value_frozen_core # shareable_constant_value: literal REGEX = /#{}/ # [Bug #20569] RUBY - assert_includes iseq_to_binary(iseq), "REGEX".b + assert_include iseq_to_binary(iseq), "REGEX".b end def test_disasm_encoding diff --git a/test/ruby/test_object.rb b/test/ruby/test_object.rb index 53ae4fb1105f47..f361bbedfbc373 100644 --- a/test/ruby/test_object.rb +++ b/test/ruby/test_object.rb @@ -373,7 +373,7 @@ def test_remove_instance_variable_re_embed # All embed_cap ivars fit - should be embedded embed_cap.times { |i| o1.instance_variable_set(:"@v#{i}", i) } - assert_includes ObjectSpace.dump(o1), '"embedded":true' + assert_include ObjectSpace.dump(o1), '"embedded":true' # One more ivar overflows embed capacity o1.instance_variable_set(:@overflow, 99) @@ -381,11 +381,11 @@ def test_remove_instance_variable_re_embed # Remove the overflow ivar - should re-embed o1.remove_instance_variable(:@overflow) - assert_includes ObjectSpace.dump(o1), '"embedded":true' + assert_include ObjectSpace.dump(o1), '"embedded":true' # An object that never overflowed is also embedded embed_cap.times { |i| o2.instance_variable_set(:"@v#{i}", i) } - assert_includes ObjectSpace.dump(o2), '"embedded":true' + assert_include ObjectSpace.dump(o2), '"embedded":true' # Verify values survived re-embedding embed_cap.times do |i| diff --git a/test/ruby/test_process.rb b/test/ruby/test_process.rb index 7b1ff61b569eaf..4ce8efb2025d08 100644 --- a/test/ruby/test_process.rb +++ b/test/ruby/test_process.rb @@ -2822,7 +2822,7 @@ def test_warmup_eager_loads_error_decoration_gems assert_empty($LOADED_FEATURES.grep(/\/(#{features.join("|")})\.rb\z/)) Process.warmup features.each do |feature| - assert_includes($LOADED_FEATURES.map { File.basename(it, ".rb") }, feature) + assert_include($LOADED_FEATURES.map { File.basename(it, ".rb") }, feature) end end; end diff --git a/test/ruby/test_set.rb b/test/ruby/test_set.rb index 427dd4b6b0977b..2ed45f72adc3ac 100644 --- a/test/ruby/test_set.rb +++ b/test/ruby/test_set.rb @@ -958,7 +958,7 @@ def test_larger_sets set = set.dup 10_000.times do |i| - assert_includes set, i + assert_include set, i end end diff --git a/test/ruby/test_settracefunc.rb b/test/ruby/test_settracefunc.rb index c1ec864002833e..54819ec16d09db 100644 --- a/test/ruby/test_settracefunc.rb +++ b/test/ruby/test_settracefunc.rb @@ -2866,8 +2866,8 @@ def test_line_event_after_guard_before_while end } - assert_includes lines, while_line - assert_includes lines, body_line + assert_include lines, while_line + assert_include lines, body_line assert_operator lines.index(while_line), :<, lines.index(body_line) end @@ -2903,7 +2903,7 @@ def read child.new.read } - assert_includes lines, while_line + assert_include lines, while_line end def test_allow_reentry diff --git a/test/ruby/test_string.rb b/test/ruby/test_string.rb index 5dfff01ec8cd79..e00d4c0522dea7 100644 --- a/test/ruby/test_string.rb +++ b/test/ruby/test_string.rb @@ -3627,7 +3627,7 @@ def test_shared_middle_string_terminator substr = str.byteslice(0, hundred.bytesize) assert_equal hundred, substr - assert_includes ObjectSpace.dump(substr), ' "shared":true,' + assert_include ObjectSpace.dump(substr), ' "shared":true,' # Larger terminator substr.force_encoding(Encoding::UTF_16BE) @@ -3648,16 +3648,16 @@ def test_substring_embed # the copy has to fit in along with the header and the terminator substr = str.byteslice(320, 128) assert_equal "a" * 128, substr - assert_includes ObjectSpace.dump(substr), ' "embedded":true,' + assert_include ObjectSpace.dump(substr), ' "embedded":true,' substr = str.byteslice(128, 320) assert_equal "a" * 320, substr - assert_includes ObjectSpace.dump(substr), ' "shared":true,' + assert_include ObjectSpace.dump(substr), ' "shared":true,' # A frozen source is a shared root itself, so the same substring is shared substr = str.freeze.byteslice(320, 128) assert_equal "a" * 128, substr - assert_includes ObjectSpace.dump(substr), ' "shared":true,' + assert_include ObjectSpace.dump(substr), ' "shared":true,' end def test_unknown_string_option @@ -3725,7 +3725,7 @@ def test_uplus_minus require 'objspace' str = "test_uplus_minus_str".freeze - assert_includes ObjectSpace.dump(str), '"fstring":true' + assert_include ObjectSpace.dump(str), '"fstring":true' assert_predicate(str, :frozen?) assert_not_predicate(+str, :frozen?) diff --git a/test/ruby/test_yjit.rb b/test/ruby/test_yjit.rb index ad0ccbfae1310d..43aae5fa46a695 100644 --- a/test/ruby/test_yjit.rb +++ b/test/ruby/test_yjit.rb @@ -18,7 +18,7 @@ class TestYJIT < Test::Unit::TestCase running_with_yjit = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? def test_yjit_in_ruby_description - assert_includes(RUBY_DESCRIPTION, '+YJIT') + assert_include(RUBY_DESCRIPTION, '+YJIT') end if running_with_yjit # Check that YJIT is in the version string @@ -63,7 +63,7 @@ def test_yjit_enable RubyVM::YJIT.enable assert_predicate RubyVM::YJIT, :enabled? - assert_includes RUBY_DESCRIPTION, "+YJIT" + assert_include RUBY_DESCRIPTION, "+YJIT" RUBY end @@ -75,7 +75,7 @@ def test_yjit_disable RubyVM::YJIT.enable assert_predicate RubyVM::YJIT, :enabled? - assert_includes RUBY_DESCRIPTION, "+YJIT" + assert_include RUBY_DESCRIPTION, "+YJIT" RUBY end diff --git a/test/ruby/test_zjit_cli.rb b/test/ruby/test_zjit_cli.rb index 7b94019067170e..3b72bb1797672b 100644 --- a/test/ruby/test_zjit_cli.rb +++ b/test/ruby/test_zjit_cli.rb @@ -61,7 +61,7 @@ def test = 42 # With --zjit-stats, stats should be printed to stderr out, err, status = eval_with_jit(script, stats: true) assert_success(out, err, status) - assert_includes(err, stats_header) + assert_include(err, stats_header) assert_equal("true\n", out) # With --zjit-stats-quiet, stats should NOT be printed but still enabled @@ -105,7 +105,7 @@ def test = 42 def test_enable_through_env child_env = {'RUBY_YJIT_ENABLE' => nil, 'RUBY_ZJIT_ENABLE' => '1'} assert_in_out_err([child_env, '-v'], '') do |stdout, stderr| - assert_includes(stdout.first, '+ZJIT') + assert_include(stdout.first, '+ZJIT') assert_equal([], stderr) end end @@ -122,7 +122,7 @@ def test_zjit_enable assert_predicate RubyVM::ZJIT, :enabled? refute_predicate RubyVM::ZJIT, :stats_enabled? - assert_includes RUBY_DESCRIPTION, "+ZJIT" + assert_include RUBY_DESCRIPTION, "+ZJIT" RUBY end @@ -134,7 +134,7 @@ def test_zjit_disable RubyVM::ZJIT.enable assert_predicate RubyVM::ZJIT, :enabled? - assert_includes RUBY_DESCRIPTION, "+ZJIT" + assert_include RUBY_DESCRIPTION, "+ZJIT" RUBY end diff --git a/test/socket/test_socket.rb b/test/socket/test_socket.rb index b286ee30c3eff4..799588f613e657 100644 --- a/test/socket/test_socket.rb +++ b/test/socket/test_socket.rb @@ -127,7 +127,7 @@ def test_ip_address_list_include_localhost rescue NotImplementedError return end - assert_includes list.map(&:ip_address), Addrinfo.tcp("localhost", 0).ip_address + assert_include list.map(&:ip_address), Addrinfo.tcp("localhost", 0).ip_address end def test_tcp diff --git a/tool/test/test_sync_default_gems.rb b/tool/test/test_sync_default_gems.rb index 314b7961f418f3..b527b07ff8d28b 100755 --- a/tool/test/test_sync_default_gems.rb +++ b/tool/test/test_sync_default_gems.rb @@ -358,7 +358,7 @@ def test_squash_merge assert_equal("# 3\n", File.read("src/lib/conflict.rb"), out) subject, body = top_commit("src", format: "%B").split("\n\n", 2) assert_equal("[ruby/#@target] Merge commit", subject, out) - assert_includes(body, "Commit in branch", out) + assert_include(body, "Commit in branch", out) end def test_no_upstream_file From b02108bfcf27a651614f39cf0edc8c2500295293 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Date: Mon, 3 Aug 2026 03:01:55 +0900 Subject: [PATCH 05/12] [ruby/io-console] Add console input event support on Windows Preserve native console input event data for Windows consumers. https://github.com/ruby/io-console/commit/c898eda906 --- ext/io/console/console.c | 127 +++++++++++++++++++++++++++++ test/io/console/test_io_console.rb | 71 ++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 80944aaef4eecf..64fdab04d62e57 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -946,6 +946,131 @@ console_set_winsize(VALUE io, VALUE size) #endif #ifdef _WIN32 +typedef struct { + HANDLE handle; + INPUT_RECORD *records; + DWORD length; + DWORD count; + BOOL result; +} read_console_input_args_t; + +static void * +nogvl_read_console_input(void *ptr) +{ + read_console_input_args_t *args = ptr; + args->result = ReadConsoleInputW(args->handle, args->records, args->length, &args->count); + return 0; +} + +static void +console_input_event_set(VALUE event, const char *name, VALUE value) +{ + rb_hash_aset(event, ID2SYM(rb_intern(name)), value); +} + +static VALUE +console_input_event(const INPUT_RECORD *record) +{ + VALUE event = rb_hash_new(); + + switch (record->EventType) { + case KEY_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("key"))); + console_input_event_set(event, "key_down", record->Event.KeyEvent.bKeyDown ? Qtrue : Qfalse); + console_input_event_set(event, "repeat_count", UINT2NUM(record->Event.KeyEvent.wRepeatCount)); + console_input_event_set(event, "virtual_key_code", UINT2NUM(record->Event.KeyEvent.wVirtualKeyCode)); + console_input_event_set(event, "virtual_scan_code", UINT2NUM(record->Event.KeyEvent.wVirtualScanCode)); + console_input_event_set(event, "unicode_char", UINT2NUM(record->Event.KeyEvent.uChar.UnicodeChar)); + console_input_event_set(event, "control_key_state", UINT2NUM(record->Event.KeyEvent.dwControlKeyState)); + break; + case MOUSE_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("mouse"))); + console_input_event_set(event, "position", rb_assoc_new( + INT2NUM(record->Event.MouseEvent.dwMousePosition.Y), + INT2NUM(record->Event.MouseEvent.dwMousePosition.X))); + console_input_event_set(event, "button_state", UINT2NUM(record->Event.MouseEvent.dwButtonState)); + console_input_event_set(event, "control_key_state", UINT2NUM(record->Event.MouseEvent.dwControlKeyState)); + console_input_event_set(event, "event_flags", UINT2NUM(record->Event.MouseEvent.dwEventFlags)); + break; + case WINDOW_BUFFER_SIZE_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("window_buffer_size"))); + console_input_event_set(event, "size", rb_assoc_new( + INT2NUM(record->Event.WindowBufferSizeEvent.dwSize.Y), + INT2NUM(record->Event.WindowBufferSizeEvent.dwSize.X))); + break; + case MENU_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("menu"))); + console_input_event_set(event, "command_id", UINT2NUM(record->Event.MenuEvent.dwCommandId)); + break; + case FOCUS_EVENT: + console_input_event_set(event, "type", ID2SYM(rb_intern("focus"))); + console_input_event_set(event, "set_focus", record->Event.FocusEvent.bSetFocus ? Qtrue : Qfalse); + break; + default: + console_input_event_set(event, "type", UINT2NUM(record->EventType)); + break; + } + + return event; +} + +/* + * call-seq: + * io.console_input_events([max_events]) -> array + * + * Reads up to +max_events+ console input events, preserving their order. + * The default is one event. Blocks until at least one event is available. + * + * Each event is returned as a Hash. The +:type+ and remaining keys are: + * + * - +:key+ : +:key_down+, +:repeat_count+, +:virtual_key_code+, + * +:virtual_scan_code+, +:unicode_char+, and +:control_key_state+. + * - +:mouse+ : +:position+ ([row, column]), +:button_state+, + * +:control_key_state+, and +:event_flags+. + * - +:window_buffer_size+ : +:size+ ([rows, columns]). + * - +:menu+ : +:command_id+. + * - +:focus+ : +:set_focus+. + * + * This method is Windows only. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_input_events(int argc, VALUE *argv, VALUE io) +{ + VALUE vmax; + DWORD max_events = 1; + read_console_input_args_t args; + VALUE event_buffer = 0; + VALUE events; + DWORD i; + + rb_scan_args(argc, argv, "01", &vmax); + if (!NIL_P(vmax)) { + max_events = NUM2UINT(vmax); + if (max_events == 0) rb_raise(rb_eArgError, "max_events must be positive"); + } + + args.handle = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); + args.records = ALLOCV_N(INPUT_RECORD, event_buffer, max_events); + args.length = max_events; + args.count = 0; + args.result = FALSE; + rb_thread_call_without_gvl(nogvl_read_console_input, &args, RUBY_UBF_IO, 0); + if (!args.result) { + int error = LAST_ERROR; + ALLOCV_END(event_buffer); + rb_syserr_fail(error, 0); + } + + events = rb_ary_new_capa(args.count); + for (i = 0; i < args.count; ++i) { + rb_ary_push(events, console_input_event(&args.records[i])); + } + ALLOCV_END(event_buffer); + return events; +} + /* * call-seq: * io.check_winsize_changed { ... } -> io @@ -974,6 +1099,7 @@ console_check_winsize_changed(VALUE io) return io; } #else +#define console_input_events rb_f_notimplement #define console_check_winsize_changed rb_f_notimplement #endif @@ -2076,6 +2202,7 @@ InitVM_console(void) rb_define_method(rb_cIO, "scroll_backward", console_scroll_backward, 1); rb_define_method(rb_cIO, "clear_screen", console_clear_screen, 0); rb_define_method(rb_cIO, "pressed?", console_key_pressed_p, 1); + rb_define_method(rb_cIO, "console_input_events", console_input_events, -1); rb_define_method(rb_cIO, "check_winsize_changed", console_check_winsize_changed, 0); rb_define_method(rb_cIO, "getpass", console_getpass, -1); rb_define_method(rb_cIO, "ttyname", console_ttyname, 0); diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 9f5911e878c595..998b7b1a908960 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -698,6 +698,77 @@ def test_pressed_invalid end end +RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and defined?(IO.console) and IO.console and \ +TestIO_Console.class_eval do + def test_console_input_events + require "fiddle" + + kernel32 = Fiddle.dlopen("kernel32.dll") + create_file = Fiddle::Function.new( + kernel32["CreateFileW"], + [Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP, + Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP], + Fiddle::TYPE_INTPTR_T, + ) + write_console_input = Fiddle::Function.new( + kernel32["WriteConsoleInputW"], + [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_LONG, Fiddle::TYPE_VOIDP], + Fiddle::TYPE_INT, + ) + close_handle = Fiddle::Function.new( + kernel32["CloseHandle"], + [Fiddle::TYPE_VOIDP], + Fiddle::TYPE_INT, + ) + + key = [1, 0, 1, 2, 0x41, 0x1e, 0x03a9, 0x18].pack("S Date: Mon, 3 Aug 2026 15:35:05 +0900 Subject: [PATCH 06/12] [ruby/io-console] Deprecate `IO#check_winsize_changed` `IO#console_input_events` preserves records that the old size change helper discards. https://github.com/ruby/io-console/commit/cea11642b3 --- ext/io/console/console.c | 10 ++++++++++ ext/io/console/extconf.rb | 2 ++ test/io/console/test_io_console.rb | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 64fdab04d62e57..7c9d749ce65fac 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -89,6 +89,10 @@ static ID id_gets, id_flush, id_chomp_bang; # define rb_interned_str_cstr(str) rb_str_freeze(rb_usascii_str_new_cstr(str)) #endif +#if !defined(HAVE_RB_CATEGORY_WARN) || !defined(HAVE_CONST_RB_WARN_CATEGORY_DEPRECATED) +# define rb_category_warn(category, ...) rb_warn(__VA_ARGS__) +#endif + #if defined HAVE_RUBY_FIBER_SCHEDULER_H # include "ruby/fiber/scheduler.h" #elif defined HAVE_RB_SCHEDULER_TIMEOUT @@ -1077,6 +1081,9 @@ console_input_events(int argc, VALUE *argv, VALUE io) * * Yields while console input events are queued. * + * Deprecated because it discards queued input events other than window buffer + * size changes. Use IO#console_input_events instead to preserve all events. + * * This method is Windows only. * * You must require 'io/console' to use this method. @@ -1087,6 +1094,9 @@ console_check_winsize_changed(VALUE io) HANDLE h; DWORD num; + rb_category_warn(RB_WARN_CATEGORY_DEPRECATED, + "IO#check_winsize_changed is deprecated; " + "use IO#console_input_events instead"); h = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); while (GetNumberOfConsoleInputEvents(h, &num) && num > 0) { INPUT_RECORD rec; diff --git a/ext/io/console/extconf.rb b/ext/io/console/extconf.rb index 95680dc8374c2c..d10bdcfe5e9d65 100644 --- a/ext/io/console/extconf.rb +++ b/ext/io/console/extconf.rb @@ -48,6 +48,8 @@ elsif have_func("rb_scheduler_timeout") # Ruby 3.0 (internal) have_func("rb_io_wait") # Ruby 3.0 end + have_func("rb_category_warn") + have_const("RB_WARN_CATEGORY_DEPRECATED") win32 or have_func("ttyname_r") or have_func("ttyname") have_func("rb_prepend_module") # not exported by TruffleRuby create_makefile("io/console") {|conf| diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 998b7b1a908960..3b057524e5c573 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -767,6 +767,12 @@ def test_console_input_events ) assert_raise(ArgumentError) {IO.console.console_input_events(0)} end + + def test_check_winsize_changed_deprecated + assert_deprecated_warning(/IO#check_winsize_changed is deprecated/) do + IO.console.check_winsize_changed {} + end + end end class TestIO_Console From d5c1e10bf840e409f1be8913f1444042a87701e4 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 3 Aug 2026 19:45:48 +0900 Subject: [PATCH 07/12] [ruby/io-console] Add cursor visibility controls Add cross-platform cursor visibility operations so console users can hide the cursor while redrawing without platform-specific code. https://github.com/ruby/io-console/commit/9db579fcf1 --- ext/io/console/console.c | 50 ++++++++++++++++++++++++++++++ test/io/console/test_io_console.rb | 40 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 7c9d749ce65fac..cfd127e651b121 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -1410,6 +1410,54 @@ console_cursor_pos(VALUE io) #endif } +static VALUE +console_cursor_visibility(VALUE io, int visible) +{ +#ifdef _WIN32 + HANDLE h = (HANDLE)rb_w32_get_osfhandle(GetWriteFD(io)); + CONSOLE_CURSOR_INFO info; + + if (!GetConsoleCursorInfo(h, &info)) { + rb_syserr_fail(LAST_ERROR, 0); + } + info.bVisible = visible; + if (!SetConsoleCursorInfo(h, &info)) { + rb_syserr_fail(LAST_ERROR, 0); + } +#else + rb_io_write(io, rb_str_new_cstr(visible ? CSI "?25h" : CSI "?25l")); +#endif + return io; +} + +/* + * call-seq: + * io.hide_cursor -> io + * + * Hides the cursor. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_hide_cursor(VALUE io) +{ + return console_cursor_visibility(io, 0); +} + +/* + * call-seq: + * io.show_cursor -> io + * + * Shows the cursor. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_show_cursor(VALUE io) +{ + return console_cursor_visibility(io, 1); +} + /* * call-seq: * io.goto(line, column) -> io @@ -2201,6 +2249,8 @@ InitVM_console(void) rb_define_method(rb_cIO, "goto", console_goto, 2); rb_define_method(rb_cIO, "cursor", console_cursor_pos, 0); rb_define_method(rb_cIO, "cursor=", console_cursor_set, 1); + rb_define_method(rb_cIO, "hide_cursor", console_hide_cursor, 0); + rb_define_method(rb_cIO, "show_cursor", console_show_cursor, 0); rb_define_method(rb_cIO, "cursor_up", console_cursor_up, 1); rb_define_method(rb_cIO, "cursor_down", console_cursor_down, 1); rb_define_method(rb_cIO, "cursor_left", console_cursor_left, 1); diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 3b057524e5c573..da8d37a274925a 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -444,6 +444,16 @@ def test_cursor_position end end + def test_cursor_visibility + run_pty(<<~'RUBY') do |r, _, _| + con = IO.console + abort unless con.hide_cursor.equal?(con) + abort unless con.show_cursor.equal?(con) + RUBY + assert_equal("\e[?25l\e[?25h", r.read(12)) + end + end unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + def assert_ctrl(expect, cc, r, w) sleep 0.1 w.print cc @@ -700,6 +710,36 @@ def test_pressed_invalid RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and defined?(IO.console) and IO.console and \ TestIO_Console.class_eval do + def test_cursor_visibility + require "fiddle/import" + + kernel32 = Module.new do + extend Fiddle::Importer + dlload "kernel32.dll" + extern "void *CreateFileW(void *, long, long, void *, long, long, void *)" + extern "int CloseHandle(void *)" + extern "int GetConsoleCursorInfo(void *, void *)" + end + File.open("CONOUT$", "r+") do |output| + info = [0, 0].pack("L Date: Mon, 3 Aug 2026 20:56:09 +0900 Subject: [PATCH 08/12] [ruby/io-console] Expose Windows output console modes Expose virtual terminal processing and line wrapping flags so console users can configure output without calling Win32 APIs directly. https://github.com/ruby/io-console/commit/dbfb2b0f8d --- ext/io/console/console.c | 77 ++++++++++++++++++++++++++++++ test/io/console/test_io_console.rb | 35 ++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index cfd127e651b121..3e68b2c074d31d 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -56,6 +56,13 @@ typedef struct sgttyb conmode; #include typedef DWORD conmode; +#ifndef ENABLE_WRAP_AT_EOL_OUTPUT +# define ENABLE_WRAP_AT_EOL_OUTPUT 0x0002 +#endif +#ifndef ENABLE_VIRTUAL_TERMINAL_PROCESSING +# define ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004 +#endif + #define LAST_ERROR rb_w32_map_errno(GetLastError()) #define SET_LAST_ERROR (errno = LAST_ERROR, 0) @@ -750,6 +757,70 @@ conmode_raw_new(int argc, VALUE *argv, VALUE obj) return conmode_new(rb_obj_class(obj), &t); } +#ifdef _WIN32 +/* + * call-seq: + * mode.virtual_terminal_processing? -> true or false + * + * Returns whether virtual terminal sequences are processed on output. + */ +static VALUE +conmode_virtual_terminal_processing_p(VALUE obj) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + return (*t & ENABLE_VIRTUAL_TERMINAL_PROCESSING) ? Qtrue : Qfalse; +} + +/* + * call-seq: + * mode.virtual_terminal_processing = enabled + * + * Enables or disables virtual terminal sequence processing in +mode+. + * Assign +mode+ to IO#console_mode= to apply the change. + */ +static VALUE +conmode_set_virtual_terminal_processing(VALUE obj, VALUE enabled) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + if (RTEST(enabled)) + *t |= ENABLE_VIRTUAL_TERMINAL_PROCESSING; + else + *t &= ~ENABLE_VIRTUAL_TERMINAL_PROCESSING; + return obj; +} + +/* + * call-seq: + * mode.wrap_at_eol_output? -> true or false + * + * Returns whether output wraps at the end of a line. + */ +static VALUE +conmode_wrap_at_eol_output_p(VALUE obj) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + return (*t & ENABLE_WRAP_AT_EOL_OUTPUT) ? Qtrue : Qfalse; +} + +/* + * call-seq: + * mode.wrap_at_eol_output = enabled + * + * Enables or disables wrapping at the end of a line in +mode+. + * Assign +mode+ to IO#console_mode= to apply the change. + */ +static VALUE +conmode_set_wrap_at_eol_output(VALUE obj, VALUE enabled) +{ + conmode *t = rb_check_typeddata(obj, &conmode_type); + if (RTEST(enabled)) + *t |= ENABLE_WRAP_AT_EOL_OUTPUT; + else + *t &= ~ENABLE_WRAP_AT_EOL_OUTPUT; + return obj; +} +#endif + /* * call-seq: * io.console_mode -> mode @@ -2310,5 +2381,11 @@ InitVM_console(void) rb_define_method(cConmode, "echo=", conmode_set_echo, 1); rb_define_method(cConmode, "raw!", conmode_set_raw, -1); rb_define_method(cConmode, "raw", conmode_raw_new, -1); +#ifdef _WIN32 + rb_define_method(cConmode, "virtual_terminal_processing?", conmode_virtual_terminal_processing_p, 0); + rb_define_method(cConmode, "virtual_terminal_processing=", conmode_set_virtual_terminal_processing, 1); + rb_define_method(cConmode, "wrap_at_eol_output?", conmode_wrap_at_eol_output_p, 0); + rb_define_method(cConmode, "wrap_at_eol_output=", conmode_set_wrap_at_eol_output, 1); +#endif } } diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index da8d37a274925a..4ee956ede2c363 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -710,6 +710,41 @@ def test_pressed_invalid RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and defined?(IO.console) and IO.console and \ TestIO_Console.class_eval do + def test_output_console_mode + require "fiddle/import" + + kernel32 = Module.new do + extend Fiddle::Importer + dlload "kernel32.dll" + extern "void *CreateFileW(void *, long, long, void *, long, long, void *)" + extern "int CloseHandle(void *)" + extern "int GetConsoleMode(void *, void *)" + end + File.open("CONOUT$", "r+") do |output| + path = "CONOUT$\0".encode("UTF-16LE") + handle = kernel32.CreateFileW(path, -0x40000000, 3, nil, 3, 0, nil) + buffer = [0].pack("L<") + assert_not_equal(0, kernel32.GetConsoleMode(handle, buffer)) + original = buffer.unpack1("L<") + mode = output.console_mode + begin + assert_equal((original & 4) != 0, mode.virtual_terminal_processing?) + assert_equal((original & 2) != 0, mode.wrap_at_eol_output?) + + mode.virtual_terminal_processing = (original & 4) == 0 + mode.wrap_at_eol_output = (original & 2) == 0 + output.console_mode = mode + assert_not_equal(0, kernel32.GetConsoleMode(handle, buffer)) + assert_equal(original ^ 6, buffer.unpack1("L<")) + ensure + mode.virtual_terminal_processing = (original & 4) != 0 + mode.wrap_at_eol_output = (original & 2) != 0 + output.console_mode = mode + kernel32.CloseHandle(handle) + end + end + end + def test_cursor_visibility require "fiddle/import" From 1c4131cdbaf4059e9bc7f015788f845a02fe6a48 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 3 Aug 2026 22:08:45 +0900 Subject: [PATCH 09/12] [ruby/io-console] Add nonblocking input status Add `IO#input_pending?` so console users can detect queued input without consuming it or relying on platform-specific APIs. https://github.com/ruby/io-console/commit/9c7cb6de0f --- ext/io/console/console.c | 38 ++++++++++++++++++++++++++++++ test/io/console/test_io_console.rb | 18 ++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 3e68b2c074d31d..819d1272ceae73 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -630,6 +630,43 @@ console_getch(int argc, VALUE *argv, VALUE io) #endif } +/* + * call-seq: + * io.input_pending? -> true or false + * + * Returns whether input can be read without blocking. + * + * You must require 'io/console' to use this method. + */ +static VALUE +console_input_pending_p(VALUE io) +{ + rb_io_t *fptr; + + GetOpenFile(io, fptr); + if (rb_io_read_pending(fptr)) return Qtrue; +#ifdef _WIN32 + { + DWORD mode; + HANDLE h = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); + + if (GetConsoleMode(h, &mode)) return _kbhit() ? Qtrue : Qfalse; + } +#endif +#if defined HAVE_RB_IO_WAIT + return RTEST(rb_io_wait(io, RB_INT2NUM(RUBY_IO_READABLE), INT2FIX(0))) ? Qtrue : Qfalse; +#else + { + struct timeval timeout = {0, 0}; + int result; + + result = rb_wait_for_single_fd(fptr->fd, RB_WAITFD_IN, &timeout); + if (result < 0) sys_fail(io); + return (result & RB_WAITFD_IN) ? Qtrue : Qfalse; + } +#endif +} + /* * call-seq: * io.noecho {|io| } @@ -2306,6 +2343,7 @@ InitVM_console(void) rb_define_method(rb_cIO, "cooked", console_cooked, 0); rb_define_method(rb_cIO, "cooked!", console_set_cooked, 0); rb_define_method(rb_cIO, "getch", console_getch, -1); + rb_define_method(rb_cIO, "input_pending?", console_input_pending_p, 0); rb_define_method(rb_cIO, "echo=", console_set_echo, 1); rb_define_method(rb_cIO, "echo?", console_echo_p, 0); rb_define_method(rb_cIO, "console_mode", console_conmode_get, 0); diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 4ee956ede2c363..c1d373b725d9f3 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -454,6 +454,18 @@ def test_cursor_visibility end end unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ + def test_input_pending + IO.pipe do |read, write| + assert_false(read.input_pending?) + write.write("ab") + assert_true(read.input_pending?) + assert_equal("a", read.getc) + assert_true(read.input_pending?) + assert_equal("b", read.getc) + assert_false(read.input_pending?) + end + end unless RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ || RUBY_ENGINE == "jruby" + def assert_ctrl(expect, cc, r, w) sleep 0.1 w.print cc @@ -809,6 +821,12 @@ def test_console_input_events records = key + resize + mouse + menu + focus assert_not_equal(0, write_console_input.call(handle, records, 5, written)) assert_equal(5, written.unpack1("L<")) + assert_true(IO.console.input_pending?) + IO.pipe do |read, write| + assert_false(read.input_pending?) + write.write("a") + assert_true(read.input_pending?) + end ensure close_handle.call(handle) end From f0511a80961529943672b79a46ff64ead5ea087b Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 3 Aug 2026 23:45:25 +0900 Subject: [PATCH 10/12] [ruby/io-console] Add interruptible console input timeout Add a `timeout` option to `IO#console_input_events` so callers can wait for input while remaining responsive to Ruby interrupts. https://github.com/ruby/io-console/commit/7801e85118 --- ext/io/console/console.c | 118 +++++++++++++++++++++++------ test/io/console/test_io_console.rb | 13 ++++ 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 819d1272ceae73..00ec0a3c56218e 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -88,7 +88,7 @@ getattr(int fd, conmode *t) #define CSI "\x1b\x5b" -static ID id_getc, id_close; +static ID id_getc, id_close, id_timeout; static ID id_gets, id_flush, id_chomp_bang; #ifndef HAVE_RB_INTERNED_STR_CSTR @@ -1058,11 +1058,20 @@ console_set_winsize(VALUE io, VALUE size) #endif #ifdef _WIN32 +enum console_input_handle_index { + console_input_handle, + console_input_wakeup, + console_input_handle_count +}; + typedef struct { - HANDLE handle; + HANDLE handles[console_input_handle_count]; INPUT_RECORD *records; DWORD length; DWORD count; + DWORD timeout; + DWORD wait_result; + DWORD error; BOOL result; } read_console_input_args_t; @@ -1070,10 +1079,27 @@ static void * nogvl_read_console_input(void *ptr) { read_console_input_args_t *args = ptr; - args->result = ReadConsoleInputW(args->handle, args->records, args->length, &args->count); + + args->wait_result = WaitForMultipleObjects(console_input_handle_count, + args->handles, FALSE, args->timeout); + if (args->wait_result == WAIT_OBJECT_0 + console_input_handle) { + args->result = ReadConsoleInputW(args->handles[console_input_handle], + args->records, args->length, &args->count); + if (!args->result) args->error = GetLastError(); + } + else if (args->wait_result == WAIT_FAILED) { + args->error = GetLastError(); + } return 0; } +static void +ubf_console_input(void *ptr) +{ + read_console_input_args_t *args = ptr; + SetEvent(args->handles[console_input_wakeup]); +} + static void console_input_event_set(VALUE event, const char *name, VALUE value) { @@ -1126,12 +1152,45 @@ console_input_event(const INPUT_RECORD *record) return event; } +static VALUE +console_input_events_read(VALUE vargs) +{ + read_console_input_args_t *args = (read_console_input_args_t *)vargs; + VALUE events; + DWORD i; + + rb_thread_call_without_gvl(nogvl_read_console_input, args, + ubf_console_input, args); + if (args->wait_result == WAIT_TIMEOUT) return rb_ary_new(); + if (args->wait_result != WAIT_OBJECT_0 + console_input_handle || + !args->result) { + rb_syserr_fail(rb_w32_map_errno(args->error), 0); + } + + events = rb_ary_new_capa(args->count); + for (i = 0; i < args->count; ++i) { + rb_ary_push(events, console_input_event(&args->records[i])); + } + return events; +} + +static VALUE +console_input_events_ensure(VALUE vargs) +{ + read_console_input_args_t *args = (read_console_input_args_t *)vargs; + + CloseHandle(args->handles[console_input_wakeup]); + xfree(args->records); + return Qnil; +} + /* * call-seq: - * io.console_input_events([max_events]) -> array + * io.console_input_events([max_events], timeout: nil) -> array * * Reads up to +max_events+ console input events, preserving their order. - * The default is one event. Blocks until at least one event is available. + * The default is one event. Blocks until at least one event is available, + * or for +timeout+ seconds if specified. Returns an empty Array on timeout. * * Each event is returned as a Hash. The +:type+ and remaining keys are: * @@ -1150,37 +1209,45 @@ console_input_event(const INPUT_RECORD *record) static VALUE console_input_events(int argc, VALUE *argv, VALUE io) { - VALUE vmax; + VALUE vmax = Qnil, vopts = Qnil, vtimeout = Qundef; + VALUE values[1]; + ID keywords[1] = {id_timeout}; DWORD max_events = 1; read_console_input_args_t args; - VALUE event_buffer = 0; - VALUE events; - DWORD i; - rb_scan_args(argc, argv, "01", &vmax); + rb_scan_args(argc, argv, "01:", &vmax, &vopts); + if (rb_get_kwargs(vopts, keywords, 0, 1, values)) { + vtimeout = values[0]; + } if (!NIL_P(vmax)) { max_events = NUM2UINT(vmax); if (max_events == 0) rb_raise(rb_eArgError, "max_events must be positive"); } - args.handle = (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); - args.records = ALLOCV_N(INPUT_RECORD, event_buffer, max_events); - args.length = max_events; - args.count = 0; - args.result = FALSE; - rb_thread_call_without_gvl(nogvl_read_console_input, &args, RUBY_UBF_IO, 0); - if (!args.result) { - int error = LAST_ERROR; - ALLOCV_END(event_buffer); - rb_syserr_fail(error, 0); + args.timeout = INFINITE; + if (!NIL_OR_UNDEF_P(vtimeout)) { + struct timeval timeout = rb_time_interval(vtimeout); + uint64_t milliseconds = (uint64_t)timeout.tv_sec * 1000; + milliseconds += ((uint64_t)timeout.tv_usec + 999) / 1000; + args.timeout = milliseconds < INFINITE ? (DWORD)milliseconds : INFINITE - 1; } - events = rb_ary_new_capa(args.count); - for (i = 0; i < args.count; ++i) { - rb_ary_push(events, console_input_event(&args.records[i])); + args.handles[console_input_handle] = + (HANDLE)rb_w32_get_osfhandle(GetReadFD(io)); + args.records = ALLOC_N(INPUT_RECORD, max_events); + args.handles[console_input_wakeup] = CreateEvent(NULL, FALSE, FALSE, NULL); + if (!args.handles[console_input_wakeup]) { + int error = LAST_ERROR; + xfree(args.records); + rb_syserr_fail(error, 0); } - ALLOCV_END(event_buffer); - return events; + args.length = max_events; + args.count = 0; + args.wait_result = WAIT_FAILED; + args.error = ERROR_SUCCESS; + args.result = FALSE; + return rb_ensure(console_input_events_read, (VALUE)&args, + console_input_events_ensure, (VALUE)&args); } /* @@ -2324,6 +2391,7 @@ Init_console(void) id_flush = rb_intern("flush"); id_chomp_bang = rb_intern("chomp!"); id_close = rb_intern("close"); + id_timeout = rb_intern("timeout"); #define init_rawmode_opt_id(name) \ rawmode_opt_ids[kwd_##name] = rb_intern(#name) init_rawmode_opt_id(min); diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index c1d373b725d9f3..65dcf388b115e8 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -859,6 +859,19 @@ def test_console_input_events events[index, 5], ) assert_raise(ArgumentError) {IO.console.console_input_events(0)} + assert_raise(ArgumentError) {IO.console.console_input_events(timeout: -1)} + + assert_equal([], IO.console.console_input_events(128, timeout: 0.01)) + started = Queue.new + thread = Thread.new do + Thread.current.report_on_exception = false + started << true + IO.console.console_input_events(128, timeout: 100) + end + started.pop + sleep 0.1 + thread.raise(Interrupt) + assert_raise(Interrupt) {thread.value} end def test_check_winsize_changed_deprecated From fad4d9d889c9d1e8cb51e34b120510288457f922 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 4 Aug 2026 03:00:35 +0900 Subject: [PATCH 11/12] [ruby/io-console] Add Windows console constants Expose Windows input constants for console event consumers. https://github.com/ruby/io-console/commit/e712668ca2 --- ext/io/console/console.c | 20 +++++++++++++- ext/io/console/extract-vk.rb | 7 +++++ ext/io/console/io-console.gemspec | 1 + ext/io/console/win32_vk.inc | 44 ++++++++++++++++++++++++++++++ test/io/console/test_io_console.rb | 24 ++++++++++++++++ 5 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 ext/io/console/extract-vk.rb diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 00ec0a3c56218e..1da37e547bfb92 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -2406,6 +2406,25 @@ Init_console(void) void InitVM_console(void) { + /* :nodoc: */ + VALUE mConsole = rb_define_module_under(rb_cIO, "Console"); +#ifdef _WIN32 + /* :nodoc: */ + VALUE mWindows = rb_define_module_under(mConsole, "Windows"); +#define define_win32_const(name) rb_define_const(mWindows, #name, UINT2NUM(name)) + EACH_VK(define_win32_const,;); + define_win32_const(RIGHT_ALT_PRESSED); + define_win32_const(LEFT_ALT_PRESSED); + define_win32_const(RIGHT_CTRL_PRESSED); + define_win32_const(LEFT_CTRL_PRESSED); + define_win32_const(SHIFT_PRESSED); + define_win32_const(NUMLOCK_ON); + define_win32_const(SCROLLLOCK_ON); + define_win32_const(CAPSLOCK_ON); + define_win32_const(ENHANCED_KEY); +#undef define_win32_const +#endif + rb_define_method(rb_cIO, "raw", console_raw, -1); rb_define_method(rb_cIO, "raw!", console_set_raw, -1); rb_define_method(rb_cIO, "cooked", console_cooked, 0); @@ -2466,7 +2485,6 @@ InitVM_console(void) } { /* :nodoc: */ - VALUE mConsole = rb_define_module_under(rb_cIO, "Console"); VALUE version = rb_obj_freeze(rb_str_new_cstr(IO_CONSOLE_VERSION)); ID cid, deprecate_constant = rb_intern_const("deprecate_constant"); rb_define_const(mConsole, "VERSION", version); diff --git a/ext/io/console/extract-vk.rb b/ext/io/console/extract-vk.rb new file mode 100644 index 00000000000000..387d5b38fdc9fa --- /dev/null +++ b/ext/io/console/extract-vk.rb @@ -0,0 +1,7 @@ +code = [+""] +ARGF.read.scan(/^\w+,\s*\KVK_\w+/) do |n| + puts("#ifndef #{n}\n# define #{n} UNDEFINED_VK\n#endif") + code << +"" if n.size + code.last.size > 60 + code.last << " x(#{n})z" +end +puts ["#define EACH_VK(x,z)", code].join(" \\\n "), "" diff --git a/ext/io/console/io-console.gemspec b/ext/io/console/io-console.gemspec index 0a199927345be6..463428d39250e7 100644 --- a/ext/io/console/io-console.gemspec +++ b/ext/io/console/io-console.gemspec @@ -45,6 +45,7 @@ Gem::Specification.new do |s| lib/ffi/io/console/native_console.rb lib/ffi/io/console/stty_console.rb lib/ffi/io/console/stub_console.rb + lib/ffi/io/console/windows_constants.rb lib/ffi/io/console/version.rb ]) end diff --git a/ext/io/console/win32_vk.inc b/ext/io/console/win32_vk.inc index b917cce9745b52..20a75b2d5b261b 100644 --- a/ext/io/console/win32_vk.inc +++ b/ext/io/console/win32_vk.inc @@ -478,6 +478,50 @@ #ifndef VK_OEM_CLEAR # define VK_OEM_CLEAR UNDEFINED_VK #endif +#define EACH_VK(x,z) \ + x(VK_LBUTTON)z x(VK_RBUTTON)z x(VK_CANCEL)z x(VK_MBUTTON)z \ + x(VK_XBUTTON1)z x(VK_XBUTTON2)z x(VK_BACK)z x(VK_TAB)z \ + x(VK_CLEAR)z x(VK_RETURN)z x(VK_SHIFT)z x(VK_CONTROL)z \ + x(VK_MENU)z x(VK_PAUSE)z x(VK_CAPITAL)z x(VK_KANA)z \ + x(VK_HANGEUL)z x(VK_HANGUL)z x(VK_JUNJA)z x(VK_FINAL)z \ + x(VK_HANJA)z x(VK_KANJI)z x(VK_ESCAPE)z x(VK_CONVERT)z \ + x(VK_NONCONVERT)z x(VK_ACCEPT)z x(VK_MODECHANGE)z x(VK_SPACE)z \ + x(VK_PRIOR)z x(VK_NEXT)z x(VK_END)z x(VK_HOME)z x(VK_LEFT)z \ + x(VK_UP)z x(VK_RIGHT)z x(VK_DOWN)z x(VK_SELECT)z x(VK_PRINT)z \ + x(VK_EXECUTE)z x(VK_SNAPSHOT)z x(VK_INSERT)z x(VK_DELETE)z \ + x(VK_HELP)z x(VK_LWIN)z x(VK_RWIN)z x(VK_APPS)z x(VK_SLEEP)z \ + x(VK_NUMPAD0)z x(VK_NUMPAD1)z x(VK_NUMPAD2)z x(VK_NUMPAD3)z \ + x(VK_NUMPAD4)z x(VK_NUMPAD5)z x(VK_NUMPAD6)z x(VK_NUMPAD7)z \ + x(VK_NUMPAD8)z x(VK_NUMPAD9)z x(VK_MULTIPLY)z x(VK_ADD)z \ + x(VK_SEPARATOR)z x(VK_SUBTRACT)z x(VK_DECIMAL)z x(VK_DIVIDE)z \ + x(VK_F1)z x(VK_F2)z x(VK_F3)z x(VK_F4)z x(VK_F5)z x(VK_F6)z \ + x(VK_F7)z x(VK_F8)z x(VK_F9)z x(VK_F10)z x(VK_F11)z x(VK_F12)z \ + x(VK_F13)z x(VK_F14)z x(VK_F15)z x(VK_F16)z x(VK_F17)z \ + x(VK_F18)z x(VK_F19)z x(VK_F20)z x(VK_F21)z x(VK_F22)z \ + x(VK_F23)z x(VK_F24)z x(VK_NUMLOCK)z x(VK_SCROLL)z \ + x(VK_OEM_NEC_EQUAL)z x(VK_OEM_FJ_JISHO)z x(VK_OEM_FJ_MASSHOU)z \ + x(VK_OEM_FJ_TOUROKU)z x(VK_OEM_FJ_LOYA)z x(VK_OEM_FJ_ROYA)z \ + x(VK_LSHIFT)z x(VK_RSHIFT)z x(VK_LCONTROL)z x(VK_RCONTROL)z \ + x(VK_LMENU)z x(VK_RMENU)z x(VK_BROWSER_BACK)z \ + x(VK_BROWSER_FORWARD)z x(VK_BROWSER_REFRESH)z \ + x(VK_BROWSER_STOP)z x(VK_BROWSER_SEARCH)z \ + x(VK_BROWSER_FAVORITES)z x(VK_BROWSER_HOME)z x(VK_VOLUME_MUTE)z \ + x(VK_VOLUME_DOWN)z x(VK_VOLUME_UP)z x(VK_MEDIA_NEXT_TRACK)z \ + x(VK_MEDIA_PREV_TRACK)z x(VK_MEDIA_STOP)z \ + x(VK_MEDIA_PLAY_PAUSE)z x(VK_LAUNCH_MAIL)z \ + x(VK_LAUNCH_MEDIA_SELECT)z x(VK_LAUNCH_APP1)z x(VK_LAUNCH_APP2)z \ + x(VK_OEM_1)z x(VK_OEM_PLUS)z x(VK_OEM_COMMA)z x(VK_OEM_MINUS)z \ + x(VK_OEM_PERIOD)z x(VK_OEM_2)z x(VK_OEM_3)z x(VK_OEM_4)z \ + x(VK_OEM_5)z x(VK_OEM_6)z x(VK_OEM_7)z x(VK_OEM_8)z \ + x(VK_OEM_AX)z x(VK_OEM_102)z x(VK_ICO_HELP)z x(VK_ICO_00)z \ + x(VK_PROCESSKEY)z x(VK_ICO_CLEAR)z x(VK_PACKET)z \ + x(VK_OEM_RESET)z x(VK_OEM_JUMP)z x(VK_OEM_PA1)z x(VK_OEM_PA2)z \ + x(VK_OEM_PA3)z x(VK_OEM_WSCTRL)z x(VK_OEM_CUSEL)z \ + x(VK_OEM_ATTN)z x(VK_OEM_FINISH)z x(VK_OEM_COPY)z \ + x(VK_OEM_AUTO)z x(VK_OEM_ENLW)z x(VK_OEM_BACKTAB)z x(VK_ATTN)z \ + x(VK_CRSEL)z x(VK_EXSEL)z x(VK_EREOF)z x(VK_PLAY)z x(VK_ZOOM)z \ + x(VK_NONAME)z x(VK_PA1)z x(VK_OEM_CLEAR)z + /* ANSI-C code produced by gperf version 3.3 */ /* Command-line: gperf --ignore-case -L ANSI-C -E -C -P -p -j1 -i 1 -g -o -t -K ofs -N console_win32_vk -k'*' win32_vk.list */ diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 65dcf388b115e8..fa145badbc6647 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -7,6 +7,10 @@ end class TestIO_Console < Test::Unit::TestCase + def test_console_namespace + assert_kind_of(Module, IO::Console) + end unless RUBY_ENGINE == "jruby" && RbConfig::CONFIG["host_os"] !~ /mswin|mingw/ + HOST_OS = RbConfig::CONFIG['host_os'] def test_version @@ -720,6 +724,26 @@ def test_pressed_invalid end end +RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and TestIO_Console.class_eval do + def test_virtual_key_constants + { + VK_TAB: 0x09, VK_RETURN: 0x0d, VK_SHIFT: 0x10, + VK_CONTROL: 0x11, VK_MENU: 0x12, VK_END: 0x23, + VK_HOME: 0x24, VK_LEFT: 0x25, VK_UP: 0x26, + VK_RIGHT: 0x27, VK_DOWN: 0x28, VK_DELETE: 0x2e, + VK_DIVIDE: 0x6f, VK_LMENU: 0xa4, + RIGHT_ALT_PRESSED: 0x0001, LEFT_ALT_PRESSED: 0x0002, + RIGHT_CTRL_PRESSED: 0x0004, LEFT_CTRL_PRESSED: 0x0008, + SHIFT_PRESSED: 0x0010, NUMLOCK_ON: 0x0020, + SCROLLLOCK_ON: 0x0040, CAPSLOCK_ON: 0x0080, + ENHANCED_KEY: 0x0100, + }.each do |name, value| + assert_equal(value, IO::Console::Windows.const_get(name, false)) + assert_false(IO::Console.const_defined?(name, false)) + end + end +end + RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and defined?(IO.console) and IO.console and \ TestIO_Console.class_eval do def test_output_console_mode From ce628310ab34797fc5e57212661bb4e1ad41f363 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 4 Aug 2026 03:04:15 +0900 Subject: [PATCH 12/12] [ruby/io-console] Add Windows support to FFI backend Let JRuby use Reline's native Windows path without Fiddle. https://github.com/ruby/io-console/commit/2b41f0f257 --- ext/io/console/io-console.gemspec | 1 + test/io/console/test_io_console.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/ext/io/console/io-console.gemspec b/ext/io/console/io-console.gemspec index 463428d39250e7..8a5093c1953070 100644 --- a/ext/io/console/io-console.gemspec +++ b/ext/io/console/io-console.gemspec @@ -46,6 +46,7 @@ Gem::Specification.new do |s| lib/ffi/io/console/stty_console.rb lib/ffi/io/console/stub_console.rb lib/ffi/io/console/windows_constants.rb + lib/ffi/io/console/windows_console.rb lib/ffi/io/console/version.rb ]) end diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index fa145badbc6647..208ea52be9e832 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -905,6 +905,23 @@ def test_check_winsize_changed_deprecated end end +RUBY_ENGINE == "jruby" && RbConfig::CONFIG["host_os"] =~ /mswin|mingw/ and \ +TestIO_Console.class_eval do + def test_jruby_windows_console_api + assert(IO::Console::Windows.const_defined?(:Native, false)) + assert_respond_to(STDIN, :console_input_events) + assert_respond_to(STDIN, :input_pending?) + assert_respond_to(STDOUT, :console_mode) + assert_respond_to(STDOUT, :hide_cursor) + assert_respond_to(STDOUT, :show_cursor) + end + + def test_jruby_windows_tty_types_are_all_validated + assert_raise(ArgumentError) {STDOUT.tty?(:any, :unknown)} + assert_raise(TypeError) {STDOUT.tty?(:any, "msys")} + end +end + class TestIO_Console def test_stringio_getch assert_ruby_status %w"--disable=gems -rstringio -rio/console", %q{