From 92888064ac7638e64c33d05ab0b0d080b48f92b2 Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Mon, 15 Jun 2026 19:57:15 -0500 Subject: [PATCH 01/15] [DOC] Harmonize lutime methods --- file.c | 53 ++++++++++++++++++++++++++++++++++++++++----- pathname_builtin.rb | 51 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/file.c b/file.c index d41ca1bb7ab258..e8aa09e4a47ff6 100644 --- a/file.c +++ b/file.c @@ -3374,14 +3374,55 @@ rb_file_s_utime(int argc, VALUE *argv, VALUE _) #if defined(HAVE_UTIMES) && (defined(HAVE_LUTIMES) || (defined(HAVE_UTIMENSAT) && defined(AT_SYMLINK_NOFOLLOW))) /* + * :markup: markdown + * * call-seq: - * File.lutime(atime, mtime, file_name, ...) -> integer + * File.lutime(atime, mtime, *paths) -> path_count + * + * Like File#utime, but does not follow symbolic links, + * and therefore changes the times of the entries given by `paths`, + * regardless of whether they are symbolic links; + * returns the number of `paths` given: + * + * ```ruby + * # Create a file and a link to it. + * file_path = 't.tmp' + * File.write(file_path, '') + * link_path = 'link' + * File.symlink(file_path, link_path) + * # Take snapshots of both. + * file_stat = File.stat(file_path) + * link_stat = File.lstat(link_path) + * # Fetch access times and modification times of both. + * file_stat.atime # => 2026-06-15 10:45:11.376753268 -0500 + * file_stat.mtime # => 2026-06-15 10:44:47.335854904 -0500 + * link_stat.atime # => 2026-06-15 10:44:59.788801128 -0500 + * link_stat.mtime # => 2026-06-15 10:44:49.367845961 -0500 + * # Update access time and modification time of the link. + * time = Time.now # => 2026-06-15 10:48:57.847422496 -0500 + * File.lutime(time, time, link_path) + * # Take fresh snapshots of both. + * file_stat = File.stat(file_path) + * link_stat = File.lstat(link_path) + * # Fetch access time and modification time of file (not changed). + * file_stat.atime # => 2026-06-15 10:45:11.376753268 -0500 + * file_stat.mtime # => 2026-06-15 10:44:47.335854904 -0500 + * # Fetch access time and modification time of link (changed). + * link_stat.atime # => 2026-06-15 10:49:27.119146136 -0500 + * link_stat.mtime # => 2026-06-15 10:48:57.847422496 -0500 + * # Clean up. + * File.delete(file_path) + * File.delete(link_path) + * ``` + * + * Arguments `atime` and `mtime` may be Time objects (as above). + * + * Either or both may be integers; + * when an integer `i` is passed, `Time.new(i)` is used. + * + * Either or both may be `nil`, in which case `Time.now` is used. * - * Sets the access and modification times of each named file to the - * first two arguments. If a file is a symlink, this method acts upon - * the link itself as opposed to its referent; for the inverse - * behavior, see File.utime. Returns the number of file - * names in the argument list. + * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. */ static VALUE diff --git a/pathname_builtin.rb b/pathname_builtin.rb index a9e5ead00c4ec9..ed6bceb70ce49c 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -1489,11 +1489,56 @@ def truncate(length) File.truncate(@path, length) end # See File.utime. Update the access and modification times. def utime(atime, mtime) File.utime(atime, mtime, @path) end - # Update the access and modification times of the file. + # :markup: markdown + # + # call-seq: + # lutime(atime, mtime) -> 1 + # + # Like Pathname#utime, but does not follow symbolic links, + # and therefore changes the times of the entry in `self`, + # regardless of whether it is a symbolic link: + # + # ```ruby + # # Create a file and a link to it. + # file_path = 't.tmp' + # link_path = 'link' + # File.write(file_path, '') + # File.symlink(file_path, link_path) + # # Take snapshots of both. + # file_stat = File.stat(file_path) + # link_stat = File.lstat(link_path) + # # Fetch access times and modification times of both. + # file_stat.atime # => 2026-06-15 11:03:29.600373255 -0500 + # file_stat.mtime # => 2026-06-15 11:03:22.247352211 -0500 + # link_stat.atime # => 2026-06-15 11:03:29.251372254 -0500 + # link_stat.mtime # => 2026-06-15 11:03:26.66436484 -0500 + # # Update access time and modification time of the link. + # pn = Pathname(link_path) + # time = Time.now # => 2026-06-15 11:08:07.384287523 -0500 + # pn.lutime(time, time) + # # Take fresh snapshots of both. + # file_stat = File.stat(file_path) + # link_stat = File.lstat(link_path) + # # Fetch access time and modification time of file (not changed). + # file_stat.atime # => 2026-06-15 11:03:29.600373255 -0500 + # file_stat.mtime # => 2026-06-15 11:03:22.247352211 -0500 + # # Fetch access time and modification time of link (changed). + # link_stat.atime # => 2026-06-15 11:08:29.847301399 -0500 + # link_stat.mtime # => 2026-06-15 11:08:07.384287523 -0500 + # # Clean up. + # File.delete(file_path) + # File.delete(link_path) + # ``` + # + # Arguments `atime` and `mtime` may be Time objects (as above). + # + # Either or both may be integers; + # when an integer `i` is passed, `Time.new(i)` is used. + # + # Either or both may be `nil`, in which case `Time.now` is used. # - # Same as Pathname#utime, but does not follow symbolic links. + # See {File System Timestamps}[rdoc-ref:file/timestamps.md]. # - # See File.lutime. def lutime(atime, mtime) File.lutime(atime, mtime, @path) end # call-seq: From 378a9c7eeea3e0e732a9fc3b49bdda836dbf2080 Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Mon, 15 Jun 2026 14:54:26 -0500 Subject: [PATCH 02/15] [DOC] Harmonize link methods --- file.c | 25 +++++++++++++++++++------ pathname_builtin.rb | 23 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/file.c b/file.c index e8aa09e4a47ff6..dfd05452fd555d 100644 --- a/file.c +++ b/file.c @@ -3468,15 +3468,28 @@ syserr_fail2_in(const char *func, int e, VALUE s1, VALUE s2) #ifdef HAVE_LINK /* + * :markup: markdown + * call-seq: - * File.link(old_name, new_name) -> 0 + * File.link(path, new_path) -> 0 + * + * Not available on some systems. * - * Creates a new name for an existing file using a hard link. Will not - * overwrite new_name if it already exists (raising a subclass - * of SystemCallError). Not available on all platforms. + * Creates a new entry at `new_path` for the existing entry at `path` + * using a [hard link](https://en.wikipedia.org/wiki/Hard_link): + * + * ```ruby + * File.write('doc/t.tmp', 'foo') + * File.link('doc/t.tmp', 'lib/u.tmp') + * File.read('lib/u.tmp') # => "foo" + * File.write('lib/u.tmp', 'bar') + * File.read('doc/t.tmp') # => "bar" + * File.delete('doc/t.tmp') + * File.read('lib/u.tmp') # => "bar" + * File.delete('lib/u.tmp') + * ``` * - * File.link("testfile", ".testfile") #=> 0 - * IO.readlines(".testfile")[0] #=> "This is line one\n" + * Raises an exception if the entry at `new_path` exists. */ static VALUE diff --git a/pathname_builtin.rb b/pathname_builtin.rb index ed6bceb70ce49c..60ae6d34129b92 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -1436,7 +1436,28 @@ def fnmatch?(pattern, ...) File.fnmatch?(pattern, @path, ...) end # Returns 'unknown' if the type cannot be determined. def ftype() File.ftype(@path) end - # See File.link. Creates a hard link. + # :markup: markdown + # + # call-seq: + # make_link(path) -> 0 + # + # Not available on some systems. + # + # Creates a new entry at the path in `self` for the existing entry at `path` + # using a [hard link](https://en.wikipedia.org/wiki/Hard_link): + # + # ```ruby + # File.write('doc/t.tmp', 'foo') + # Pathname('lib/u.tmp').make_link('doc/t.tmp') + # File.read('lib/u.tmp') # => "foo" + # File.write('lib/u.tmp', 'bar') + # File.read('doc/t.tmp') # => "bar" + # File.delete('doc/t.tmp') + # File.read('lib/u.tmp') # => "bar" + # File.delete('lib/u.tmp') + # ``` + # + # Raises an exception if the entry at the path in `self` exists. def make_link(old) File.link(old, @path) end # See File.open. Opens the file for reading or writing. From 8d076d2e3aebe89d7c179e460f983f3dacdbc1ca Mon Sep 17 00:00:00 2001 From: BurdetteLamar Date: Mon, 15 Jun 2026 11:42:48 -0500 Subject: [PATCH 03/15] [DOC] Add utime methods --- doc/file/timestamps.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/file/timestamps.md b/doc/file/timestamps.md index c8ad616567bd35..60934edb05e678 100644 --- a/doc/file/timestamps.md +++ b/doc/file/timestamps.md @@ -51,6 +51,13 @@ Each of these methods returns the modification time for an entry as a Time objec - File::Stat#mtime. - Pathname#mtime. +The modification time (along with the access time) may also be updated explicitly: + +- File::lutime. +- File::utime. +- Pathname#lutime. +- Pathname#utime. + ## Access \Time The access time for an entry is the time the entry last read. @@ -64,6 +71,13 @@ Each of these methods returns the access time for an entry as a Time object: - File::Stat#atime. - Pathname#atime. +The access time (along with the modification time) may also be updated explicitly: + +- File::lutime. +- File::utime. +- Pathname#lutime. +- Pathname#utime. + ## Metadata-Change \Time The metadata-change time for an entry is the time the entry last read. From 09847a62a3a4b5f8c25ec406322dc52929ce002a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Mon, 15 Jun 2026 19:49:57 +0900 Subject: [PATCH 04/15] [DOC] Fix String#split docs When `limit` is negative, the trailing empty strings are included. --- doc/string/split.rdoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/string/split.rdoc b/doc/string/split.rdoc index 8679149003fdd2..dc6292d182b192 100644 --- a/doc/string/split.rdoc +++ b/doc/string/split.rdoc @@ -68,7 +68,7 @@ and trailing empty strings are included: When +limit+ is negative, there is no limit on the size of the array, -and trailing empty strings are omitted: +and trailing empty strings are included: 'abracadabra'.split('', -1) # => ["a", "b", "r", "a", "c", "a", "d", "a", "b", "r", "a", ""] 'abracadabra'.split('a', -1) # => ["", "br", "c", "d", "br", ""] From 2093738aad2f082dd68f8a8a6c3edb11c33ac9bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:08:39 +0000 Subject: [PATCH 05/15] Bump taiki-e/install-action Bumps the github-actions group with 1 update in the / directory: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.81.10 to 2.81.11 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/7a79fe8c3a13344501c80d99cae481c1c9085912...15449e3094499af05d8d964a1c884208e4b8b595) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.81.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/zjit-macos.yml | 2 +- .github/workflows/zjit-ubuntu.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zjit-macos.yml b/.github/workflows/zjit-macos.yml index 3340ba08a75dc8..4921d9ef70a875 100644 --- a/.github/workflows/zjit-macos.yml +++ b/.github/workflows/zjit-macos.yml @@ -93,7 +93,7 @@ jobs: rustup install ${{ matrix.rust_version }} --profile minimal rustup default ${{ matrix.rust_version }} - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 + - uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} diff --git a/.github/workflows/zjit-ubuntu.yml b/.github/workflows/zjit-ubuntu.yml index 8fd1ef9b7a2010..30c6138466fe21 100644 --- a/.github/workflows/zjit-ubuntu.yml +++ b/.github/workflows/zjit-ubuntu.yml @@ -125,7 +125,7 @@ jobs: ruby-version: '3.1' bundler: none - - uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10 + - uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11 with: tool: nextest@0.9 if: ${{ matrix.test_task == 'zjit-check' }} From 25c1df0a95847695c42a7a3c970afa2387fe2d89 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 12 Jun 2026 13:39:27 +0900 Subject: [PATCH 06/15] Fix stale runnable_hot_th hint causing assertion failure with MN threads The hot thread cannot steal back control when sched->running is an MN thread, and goes to sleep with sched->runnable_hot_th still pointing to itself, firing VM_ASSERT(sched->runnable_hot_th != th) at the next wakeup. Drop the hint before sleeping since the thread is no longer spinning. On a VM_CHECK_MODE build with RUBY_MN_THREADS=1: Thread.new { loop {} } 100.times { File.read(IO::NULL) } Co-Authored-By: Claude Fable 5 --- thread_pthread.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/thread_pthread.c b/thread_pthread.c index 5b5329fd217916..44a3ce4270758f 100644 --- a/thread_pthread.c +++ b/thread_pthread.c @@ -866,6 +866,13 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b thread_sched_set_running(sched, th); rb_ractor_thread_switch(th->ractor, th, false); } + else if (th == sched->runnable_hot_th) { + // The hot thread cannot steal the control (e.g. the running thread + // is an MN thread). It is going to sleep, so it is no longer spinning; + // drop the hint so that other threads don't yield the lock to it. + sched->runnable_hot_th = NULL; + sched->runnable_hot_th_waiting = 0; + } // already deleted from running threads // VM_ASSERT(!ractor_sched_running_threads_contain_p(th->vm, th)); // need locking From b4d1813aefad8eea76722eb64b6fc7e30877405c Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Thu, 18 Dec 2025 14:43:05 +0100 Subject: [PATCH 07/15] [ruby/rubygems] Implement a make jobserver: - Fix https://github.com/ruby/rubygems/pull/9170 - In #9131, we added running `make` in parallel with the `-j` flag. It's problematic because since Bundler installs gems in parallel, we could end up installing `N gem x M processors` which would exhaust the machine resources and causes issues like #9170. In this patch, I have implemented a make [jobserver](https://www.gnu.org/software/make/manual/html_node/POSIX-Jobserver.html), so that each thread installing a gem can take available job slots from the pool. I also want to highlight that with this patch, we can still end up running 2 more jobs than what's available. The reason being that if a gem is waiting for slots to be available, then the whole `bundle install` takes much longer which defeats the whole purpose of the `make -j` optimization. So if there is no more slots available, we compile the extension without parallelization (we still use one cpu though). Example ---------- If `bundle install -j 6` is running, then we have 6 slots available. Each gem with native extension that gets installed may take up to *3* slots (see my reasoning on this number below). In the event where 3 gems with native extensions get installed, 2 gems will consume all 6 slots, leaving the third gem without any. Ultimately, this means that we could use *at maximum 2 more slots than what's available* (e.g. `bundle install -j 3` for 3 gems will use 5 slots). I chose `3` slots per `make` process because after installing multiple gems, I didn't see any benefit to adding more jobs. It's possible that some gems have many `make` recipes that could run more than 3 in parallel, but I don't think this is very frequent. https://github.com/ruby/rubygems/commit/664a6ff9c2 --- lib/bundler/installer/parallel_installer.rb | 25 +++- lib/bundler/rubygems_gem_installer.rb | 24 +++- lib/rubygems/ext/builder.rb | 3 +- .../installer/parallel_installer_spec.rb | 131 ++++++++++++++++++ spec/bundler/commands/install_spec.rb | 8 +- 5 files changed, 182 insertions(+), 9 deletions(-) diff --git a/lib/bundler/installer/parallel_installer.rb b/lib/bundler/installer/parallel_installer.rb index fef326ed0a9c8a..3f2da40fb377de 100644 --- a/lib/bundler/installer/parallel_installer.rb +++ b/lib/bundler/installer/parallel_installer.rb @@ -110,10 +110,29 @@ def failed_specs end def install_with_worker - installed_specs = {} - enqueue_specs(installed_specs) + with_jobserver do + installed_specs = {} + enqueue_specs(installed_specs) - process_specs(installed_specs) until finished_installing? + process_specs(installed_specs) until finished_installing? + end + end + + def with_jobserver + r, w = IO.pipe + r.close_on_exec = false + w.close_on_exec = false + w.write("*" * @size) + + old_makeflags = ENV["MAKEFLAGS"] + ENV["MAKEFLAGS"] = "#{old_makeflags} --jobserver-auth=#{r.fileno},#{w.fileno}" + + yield + ensure + r.close + w.close + + old_makeflags ? ENV["MAKEFLAGS"] = old_makeflags : ENV.delete("MAKEFLAGS") end def install_serially diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index fc019f54d2403a..681f5cf154f0fc 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -124,10 +124,18 @@ def generate_bin_script(filename, bindir) end def build_jobs - Bundler.settings[:jobs] || super + @jobserver_read_io&.read_nonblock(3, @jobserver_tokens) + available_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size + + available_jobs || Bundler.settings[:jobs] || super + rescue IO::WaitReadable + 1 end def build_extensions + @jobserver_tokens = +"" + @jobserver_read_io, @jobserver_write_io = connect_to_jobserver + extension_cache_path = options[:bundler_extension_cache_path] extension_dir = spec.extension_dir unless extension_cache_path && extension_dir @@ -151,6 +159,11 @@ def build_extensions FileUtils.cp_r extension_dir, extension_cache_path end end + ensure + unless @jobserver_tokens.empty? + @jobserver_write_io.write(@jobserver_tokens) + @jobserver_write_io.flush + end end def spec @@ -167,6 +180,15 @@ def gem_checksum private + def connect_to_jobserver + return unless ENV["MAKEFLAGS"] + read_fd, write_fd = ENV["MAKEFLAGS"].match(/--jobserver-auth=(\d+),(\d+)/)&.captures + + return unless read_fd && write_fd + + [IO.new(read_fd.to_i, autoclose: false), IO.new(write_fd.to_i, autoclose: false)] + end + def prepare_extension_build(extension_dir) SharedHelpers.filesystem_access(extension_dir, :create) do FileUtils.mkdir_p extension_dir diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index e00cf159da3e7c..f496b81de723bb 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -41,8 +41,9 @@ def self.make(dest_path, results, make_dir = Dir.pwd, sitedir = nil, targets = [ # nmake doesn't support parallel build unless is_nmake have_make_arguments = make_program.size > 1 + n_jobs ||= 0 - if !have_make_arguments && !ENV["MAKEFLAGS"] && n_jobs + if !have_make_arguments && n_jobs > 1 && !ENV["MAKEFLAGS"]&.match(/-j\d?(\s|\Z)/) make_program << "-j#{n_jobs}" end end diff --git a/spec/bundler/bundler/installer/parallel_installer_spec.rb b/spec/bundler/bundler/installer/parallel_installer_spec.rb index 49bcb5310ba90e..224315afc92033 100644 --- a/spec/bundler/bundler/installer/parallel_installer_spec.rb +++ b/spec/bundler/bundler/installer/parallel_installer_spec.rb @@ -76,4 +76,135 @@ parallel_installer.call end end + + describe "connect to make jobserver" do + before do + unless Gem::Installer.private_method_defined?(:build_jobs) + skip "This example is runnable when RubyGems::Installer implements `build_jobs`" + end + + require "support/artifice/compact_index" + + @previous_client = Gem::Request::ConnectionPools.client + Gem::Request::ConnectionPools.client = Gem::Net::HTTP + Gem::RemoteFetcher.fetcher.close_all + + build_repo2 do + build_gem "one", &:add_c_extension + build_gem "two", &:add_c_extension + end + + gemfile <<~G + source "https://gem.repo2" + + gem "one" + gem "two" + G + lockfile <<~L + GEM + remote: https://gem.repo2/ + specs: + one (1.0) + two (1.0) + + DEPENDENCIES + one + two + L + + @old_ui = Bundler.ui + Bundler.ui = Bundler::UI::Silent.new + end + + after do + Bundler.ui = @old_ui + Gem::Request::ConnectionPools.client = @previous_client + Artifice.deactivate + end + + let(:definition) do + allow(Bundler).to receive(:root) { bundled_app } + + definition = Bundler::Definition.build(bundled_app.join("Gemfile"), bundled_app.join("Gemfile.lock"), false) + definition.tap(&:setup_domain!) + end + let(:installer) { Bundler::Installer.new(bundled_app, definition) } + let(:gem_one) { definition.specs.find {|spec| spec.name == "one" } } + let(:gem_two) { definition.specs.find {|spec| spec.name == "two" } } + + it "takes all available slots" do + redefine_build_jobs do + Bundler::ParallelInstaller.call(installer, definition.specs, 5, false, true) + end + + # Take 3 slots out of the 5 available. + expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3") + # Take the remaining 2 slots. + expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to include("make -j2") + end + + it "fallback to non parallel when no slots are available" do + redefine_build_jobs do + Bundler::ParallelInstaller.call(installer, definition.specs, 3, false, true) + end + + # Take 3 slots out of the 3 available. + expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3") + # Fallback to one slot (non parallel). + expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to_not include("make -j") + end + + it "uses one jobs when installing serially" do + Bundler.settings.temporary(jobs: 1) do + Bundler::ParallelInstaller.call(installer, definition.specs, 1, false, true) + end + + expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to_not include("make -j") + expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to_not include("make -j") + end + + it "release the job slots" do + build_repo2 do + build_gem "one", &:add_c_extension + build_gem "two" do |spec| + spec.add_c_extension + spec.add_dependency(:one) # ParallelInstaller will wait for `one` to be fully installed. + end + end + + Bundler::ParallelInstaller.call(installer, definition.specs, 3, false, true) + + # Take 3 slots out of the 3 available. + expect(File.read(File.join(gem_one.extension_dir, "gem_make.out"))).to include("make -j3") + # Take 3 slots that were released. + expect(File.read(File.join(gem_two.extension_dir, "gem_make.out"))).to include("make -j3") + end + + def redefine_build_jobs + old_method = Bundler::RubyGemsGemInstaller.instance_method(:build_jobs) + Bundler::RubyGemsGemInstaller.remove_method(:build_jobs) + + gem_one_waiting = true + gem_two_waiting = true + + Bundler::RubyGemsGemInstaller.define_method(:build_jobs) do + if spec.name == "one" + value = old_method.bind(self).call + gem_one_waiting = false + sleep(0.1) while gem_two_waiting + elsif spec.name == "two" + sleep(0.1) while gem_one_waiting + value = old_method.bind(self).call + gem_two_waiting = false + end + + value + end + + yield + ensure + Bundler::RubyGemsGemInstaller.remove_method(:build_jobs) + Bundler::RubyGemsGemInstaller.define_method(:build_jobs, old_method) + end + end end diff --git a/spec/bundler/commands/install_spec.rb b/spec/bundler/commands/install_spec.rb index 3b24434dc724f1..18c3fd65038c16 100644 --- a/spec/bundler/commands/install_spec.rb +++ b/spec/bundler/commands/install_spec.rb @@ -1389,7 +1389,7 @@ def run expect(gem_make_out).not_to include("make -j8") end - it "pass down the BUNDLE_JOBS to RubyGems when running the compilation of an extension" do + it "uses 3 slots from the available pool when running the compilation of an extension" do ENV.delete("MAKEFLAGS") install_gemfile(<<~G, env: { "BUNDLE_JOBS" => "8" }) @@ -1399,10 +1399,10 @@ def run gem_make_out = File.read(File.join(@gemspec.extension_dir, "gem_make.out")) - expect(gem_make_out).to include("make -j8") + expect(gem_make_out).to include("make -j3") end - it "uses nprocessors by default" do + it "consumes 3 slots from the pool when BUNDLE_JOBS isn't set" do ENV.delete("MAKEFLAGS") install_gemfile(<<~G) @@ -1412,7 +1412,7 @@ def run gem_make_out = File.read(File.join(@gemspec.extension_dir, "gem_make.out")) - expect(gem_make_out).to include("make -j#{Etc.nprocessors + 1}") + expect(gem_make_out).to include("make -j3") end end From 2ef299391f2c2ee7950589be3785f4c5ba3fe778 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 10:41:05 +0900 Subject: [PATCH 08/15] [ruby/rubygems] Open the jobserver write end in write mode IO.new defaults to read mode when no mode is given. On POSIX, Ruby inspects the descriptor's access mode and opens the write end of the jobserver pipe for writing anyway, but on Windows that inspection is not available, so releasing slots raised "IOError: not opened for writing". Pass explicit modes so the write end is usable on every platform. https://github.com/ruby/rubygems/commit/9d17c614dd Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bundler/rubygems_gem_installer.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index 681f5cf154f0fc..90ce057a69d04d 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -186,7 +186,10 @@ def connect_to_jobserver return unless read_fd && write_fd - [IO.new(read_fd.to_i, autoclose: false), IO.new(write_fd.to_i, autoclose: false)] + # Pass explicit modes. On POSIX, IO.new detects the descriptor's access + # mode, but on Windows it can't, so the write end would default to read + # mode and raise "IOError: not opened for writing" when releasing slots. + [IO.new(read_fd.to_i, "r", autoclose: false), IO.new(write_fd.to_i, "w", autoclose: false)] end def prepare_extension_build(extension_dir) From 35bb14bebb5dcfe1758b497018f347bf9e398408 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 11:43:13 +0900 Subject: [PATCH 09/15] [ruby/rubygems] Detect multi-digit -j in MAKEFLAGS The guard that avoids appending a duplicate -j used /-j\d?(\s|\Z)/, but \d? matches at most one digit, so an existing -j10 (or any two-digit job count) failed to match and a second -j was appended anyway. Use \d* so a job count of any width is recognized. https://github.com/ruby/rubygems/commit/2bb45e9cd4 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/rubygems/ext/builder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rubygems/ext/builder.rb b/lib/rubygems/ext/builder.rb index f496b81de723bb..53a02f61f31b0d 100644 --- a/lib/rubygems/ext/builder.rb +++ b/lib/rubygems/ext/builder.rb @@ -43,7 +43,7 @@ def self.make(dest_path, results, make_dir = Dir.pwd, sitedir = nil, targets = [ have_make_arguments = make_program.size > 1 n_jobs ||= 0 - if !have_make_arguments && n_jobs > 1 && !ENV["MAKEFLAGS"]&.match(/-j\d?(\s|\Z)/) + if !have_make_arguments && n_jobs > 1 && !ENV["MAKEFLAGS"]&.match(/-j\d*(\s|\Z)/) make_program << "-j#{n_jobs}" end end From 71dd0b6a110fb27e4897bf543d189329e537d52d Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 11:43:40 +0900 Subject: [PATCH 10/15] [ruby/rubygems] Read the jobserver auth that Bundler appended connect_to_jobserver matched the first --jobserver-auth in MAKEFLAGS, but the value is appended after any pre-existing flags. When bundle install runs under a parent make jobserver, MAKEFLAGS already carries the parent's --jobserver-auth, so the leftmost match returned the parent's descriptors instead of the pool ParallelInstaller just created. Scan and take the last match so we always connect to our own pipe. https://github.com/ruby/rubygems/commit/e42c907190 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bundler/rubygems_gem_installer.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index 90ce057a69d04d..d050ecf215d9ff 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -182,7 +182,10 @@ def gem_checksum def connect_to_jobserver return unless ENV["MAKEFLAGS"] - read_fd, write_fd = ENV["MAKEFLAGS"].match(/--jobserver-auth=(\d+),(\d+)/)&.captures + # We append our own --jobserver-auth, so read the last one. Otherwise a + # parent jobserver's descriptors (e.g. `bundle install` run under + # `make -j`) would be picked up instead of the pool we just created. + read_fd, write_fd = ENV["MAKEFLAGS"].scan(/--jobserver-auth=(\d+),(\d+)/).last return unless read_fd && write_fd From 907821b4da16f6621385a4bb0b5914e801827683 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 11:43:56 +0900 Subject: [PATCH 11/15] [ruby/rubygems] Restore MAKEFLAGS before closing the jobserver pipe If the read end raised while closing, the original ensure skipped both the write-end close and the MAKEFLAGS restore, leaving --jobserver-auth with closed descriptors in the process environment. Restore MAKEFLAGS first and guard the closes so the environment is always cleaned up. Building the value with compact.join also drops the stray leading space when MAKEFLAGS was previously unset. https://github.com/ruby/rubygems/commit/ae2530f941 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bundler/installer/parallel_installer.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/bundler/installer/parallel_installer.rb b/lib/bundler/installer/parallel_installer.rb index 3f2da40fb377de..619ed14a7d4f9b 100644 --- a/lib/bundler/installer/parallel_installer.rb +++ b/lib/bundler/installer/parallel_installer.rb @@ -125,14 +125,16 @@ def with_jobserver w.write("*" * @size) old_makeflags = ENV["MAKEFLAGS"] - ENV["MAKEFLAGS"] = "#{old_makeflags} --jobserver-auth=#{r.fileno},#{w.fileno}" + ENV["MAKEFLAGS"] = [old_makeflags, "--jobserver-auth=#{r.fileno},#{w.fileno}"].compact.join(" ") yield ensure - r.close - w.close - + # Restore MAKEFLAGS before closing the pipe so a close failure can't + # leave the process with descriptors that point at a closed pipe. old_makeflags ? ENV["MAKEFLAGS"] = old_makeflags : ENV.delete("MAKEFLAGS") + + r&.close + w&.close end def install_serially From 1d4a353a9bb890d3230ae31727e3f10d171f6b28 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 11:44:19 +0900 Subject: [PATCH 12/15] [ruby/rubygems] Synchronize the jobserver specs without busy-waiting redefine_build_jobs coordinated the two worker threads with `sleep(0.1) while flag` loops. If a thread died before clearing its flag the other spun forever, and the polling added latency to every run. Use blocking Thread::Queue handoffs so the ordering is deterministic and a failure surfaces instead of hanging on a flag that never flips. https://github.com/ruby/rubygems/commit/3f6745222d Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bundler/installer/parallel_installer_spec.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/bundler/bundler/installer/parallel_installer_spec.rb b/spec/bundler/bundler/installer/parallel_installer_spec.rb index 224315afc92033..c26f2889a10d82 100644 --- a/spec/bundler/bundler/installer/parallel_installer_spec.rb +++ b/spec/bundler/bundler/installer/parallel_installer_spec.rb @@ -184,18 +184,21 @@ def redefine_build_jobs old_method = Bundler::RubyGemsGemInstaller.instance_method(:build_jobs) Bundler::RubyGemsGemInstaller.remove_method(:build_jobs) - gem_one_waiting = true - gem_two_waiting = true + # Rendezvous so that "one" grabs its slots first and keeps holding them + # until "two" has grabbed the rest. Blocking on a queue avoids the + # busy-wait and makes the ordering deterministic. + one_acquired = Thread::Queue.new + two_acquired = Thread::Queue.new Bundler::RubyGemsGemInstaller.define_method(:build_jobs) do if spec.name == "one" value = old_method.bind(self).call - gem_one_waiting = false - sleep(0.1) while gem_two_waiting + one_acquired << true + two_acquired.pop elsif spec.name == "two" - sleep(0.1) while gem_one_waiting + one_acquired.pop value = old_method.bind(self).call - gem_two_waiting = false + two_acquired << true end value From 035d59ff381b039fa69c803a9e39f8a10980183e Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 11:44:44 +0900 Subject: [PATCH 13/15] [ruby/rubygems] Clarify build_jobs slot handling Name the per-gem slot cap MAX_JOBS_PER_GEM with a comment explaining the limit, rename the local to acquired_jobs since it counts grabbed tokens rather than free ones, and rescue EOFError alongside IO::WaitReadable so a closed jobserver pipe falls back to a single job instead of raising. https://github.com/ruby/rubygems/commit/746ffc2381 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bundler/rubygems_gem_installer.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index d050ecf215d9ff..f3b12a1a5c9b64 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -4,6 +4,11 @@ module Bundler class RubyGemsGemInstaller < Gem::Installer + # Cap how many jobserver slots a single gem's `make` may grab so that one + # gem with many recipes doesn't starve the others sharing the pool. Beyond + # a handful of jobs the extra parallelism rarely pays off in practice. + MAX_JOBS_PER_GEM = 3 + def check_executable_overwrite(filename) # Bundler needs to install gems regardless of binstub overwriting end @@ -124,11 +129,11 @@ def generate_bin_script(filename, bindir) end def build_jobs - @jobserver_read_io&.read_nonblock(3, @jobserver_tokens) - available_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size + @jobserver_read_io&.read_nonblock(MAX_JOBS_PER_GEM, @jobserver_tokens) + acquired_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size - available_jobs || Bundler.settings[:jobs] || super - rescue IO::WaitReadable + acquired_jobs || Bundler.settings[:jobs] || super + rescue IO::WaitReadable, EOFError 1 end From bb2a915dbda4ebfffbb4b1fc5c8545509232fb8a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 16 Jun 2026 12:12:48 +0900 Subject: [PATCH 14/15] [ruby/rubygems] Reword jobserver comment to satisfy quality spec Bundler's quality spec rejects weak modifiers in lib source, and the connect_to_jobserver comment used "the pool we just created". Name ParallelInstaller instead so the meaning stays clear without "just". https://github.com/ruby/rubygems/commit/a9818a7119 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/bundler/rubygems_gem_installer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb index f3b12a1a5c9b64..c6313ddf8d0eb7 100644 --- a/lib/bundler/rubygems_gem_installer.rb +++ b/lib/bundler/rubygems_gem_installer.rb @@ -189,7 +189,7 @@ def connect_to_jobserver return unless ENV["MAKEFLAGS"] # We append our own --jobserver-auth, so read the last one. Otherwise a # parent jobserver's descriptors (e.g. `bundle install` run under - # `make -j`) would be picked up instead of the pool we just created. + # `make -j`) would be picked up instead of the pool ParallelInstaller created. read_fd, write_fd = ENV["MAKEFLAGS"].scan(/--jobserver-auth=(\d+),(\d+)/).last return unless read_fd && write_fd From 2a2523438d9bd6f6730ceba7703d9966b7df5bb4 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 16 Jun 2026 08:09:31 +0200 Subject: [PATCH 15/15] [ruby/json] ResumableParser: Reset err_info on EOS errors Fix: https://github.com/ruby/json/issues/1008 I'm unfortunately unable to turn the reproduction script into a test case there, I don't understand what is causing `err_info` to be re-raised in the repro that doesn't work in test-unit. ```ruby require 'json' parser = JSON::ResumableParser.new({}) ['{"message": "hello ', 'world"}', '[1,2]'].each do |chunk| parser << chunk begin while parser.parse p parser.value end rescue JSON::ParserError => e p e parser.clear end end ``` https://github.com/ruby/json/commit/24a2b083d7 --- ext/json/parser/parser.c | 1 + 1 file changed, 1 insertion(+) diff --git a/ext/json/parser/parser.c b/ext/json/parser/parser.c index e722a1a72e9bdf..fa66d0847cee7f 100644 --- a/ext/json/parser/parser.c +++ b/ext/json/parser/parser.c @@ -2412,6 +2412,7 @@ static VALUE cResumableParser_parse(VALUE self) complete = false; if (RTEST(rb_ivar_get(rb_errinfo(), rb_intern("@eos")))) { complete = false; // is an EOS error + rb_set_errinfo(Qnil); } else { rb_jump_tag(status); // reraise }