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' }}
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.
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", ""]
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
}
diff --git a/file.c b/file.c
index d41ca1bb7ab258..dfd05452fd555d 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
@@ -3427,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 entry at `new_path` for the existing entry at `path`
+ * using a [hard link](https://en.wikipedia.org/wiki/Hard_link):
*
- * 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.
+ * ```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/lib/bundler/installer/parallel_installer.rb b/lib/bundler/installer/parallel_installer.rb
index fef326ed0a9c8a..619ed14a7d4f9b 100644
--- a/lib/bundler/installer/parallel_installer.rb
+++ b/lib/bundler/installer/parallel_installer.rb
@@ -110,10 +110,31 @@ 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}"].compact.join(" ")
+
+ yield
+ ensure
+ # 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
diff --git a/lib/bundler/rubygems_gem_installer.rb b/lib/bundler/rubygems_gem_installer.rb
index fc019f54d2403a..c6313ddf8d0eb7 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,10 +129,18 @@ def generate_bin_script(filename, bindir)
end
def build_jobs
- Bundler.settings[:jobs] || super
+ @jobserver_read_io&.read_nonblock(MAX_JOBS_PER_GEM, @jobserver_tokens)
+ acquired_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size
+
+ acquired_jobs || Bundler.settings[:jobs] || super
+ rescue IO::WaitReadable, EOFError
+ 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 +164,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 +185,21 @@ def gem_checksum
private
+ 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 ParallelInstaller created.
+ read_fd, write_fd = ENV["MAKEFLAGS"].scan(/--jobserver-auth=(\d+),(\d+)/).last
+
+ return unless read_fd && write_fd
+
+ # 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)
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..53a02f61f31b0d 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/pathname_builtin.rb b/pathname_builtin.rb
index a9e5ead00c4ec9..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.
@@ -1489,11 +1510,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:
diff --git a/spec/bundler/bundler/installer/parallel_installer_spec.rb b/spec/bundler/bundler/installer/parallel_installer_spec.rb
index 49bcb5310ba90e..c26f2889a10d82 100644
--- a/spec/bundler/bundler/installer/parallel_installer_spec.rb
+++ b/spec/bundler/bundler/installer/parallel_installer_spec.rb
@@ -76,4 +76,138 @@
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)
+
+ # 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
+ one_acquired << true
+ two_acquired.pop
+ elsif spec.name == "two"
+ one_acquired.pop
+ value = old_method.bind(self).call
+ two_acquired << true
+ 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
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