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