Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d216d5f
[DOC] Document that RUBY_MAX_CPU affects ractors
jeremyevans May 24, 2026
6a03c47
[ruby/rubygems] Add Gem::CompactIndexClient::CacheFile
hsbt Jun 10, 2026
2aa1fc1
[ruby/rubygems] Add Gem::CompactIndexClient::Updater
hsbt Jun 10, 2026
6ceda5e
[ruby/rubygems] Add Gem::CompactIndexClient::Cache
hsbt Jun 10, 2026
3272a56
[ruby/rubygems] Add Gem::CompactIndexClient::Parser
hsbt Jun 10, 2026
38d2d94
[ruby/rubygems] Add Gem::CompactIndexClient public API
hsbt Jun 10, 2026
6f9b647
[ruby/rubygems] Add Gem::CompactIndexClient::HTTPFetcher
hsbt Jun 10, 2026
bedb9f7
[ruby/rubygems] Add compact index stub helpers for tests
hsbt Jun 10, 2026
a3cea2d
[ruby/rubygems] Add Gem::CompactIndexClient#fetch_info
hsbt Jun 10, 2026
8c98564
[ruby/rubygems] Fetch the compact index in APISet through Gem::Compac…
hsbt Jun 10, 2026
fccf659
[ruby/rubygems] Expose created_at on Gem::Resolver::APISpecification
hsbt Jun 10, 2026
6e1d230
[ruby/rubygems] Build APISpecification#spec from compact index data
hsbt Jun 10, 2026
f82503b
[ruby/rubygems] Load spec name tuples from the compact index in Gem::…
hsbt Jun 10, 2026
6302a6e
[ruby/rubygems] Add compact index coverage for gem update and gem out…
hsbt Jun 10, 2026
2a196d4
[ruby/rubygems] Skip malformed digest header parameters
hsbt Jun 10, 2026
dd14ab8
[ruby/rubygems] Follow 308 redirects in compact index HTTPFetcher
hsbt Jun 10, 2026
20c070f
[ruby/rubygems] Reuse compact_index_uri in new_dependency_resolver_set
hsbt Jun 10, 2026
9d6898b
[ruby/rubygems] Guard pathname require with defined?(Pathname)
hsbt Jun 10, 2026
a640b59
[ruby/rubygems] Write compact index test fixtures in binary mode
hsbt Jun 10, 2026
dee345d
[ruby/rubygems] Skip malformed versions instead of dropping the compa…
hsbt Jun 16, 2026
7c15faa
[ruby/rubygems] Clean up the compact index tmpdir when the cache is n…
hsbt Jun 16, 2026
b3fa268
[ruby/rubygems] Parse compact index created_at strictly as ISO8601
hsbt Jun 16, 2026
4fa03be
ZJIT: Support inlining methods that dispatch to blocks (GH-17376)
nirvdrum Jun 18, 2026
d4f36d1
Bump taiki-e/install-action
dependabot[bot] Jun 18, 2026
0f4f0b6
[ruby/rubygems] Probe socket errors via SO_ERROR in TCPSocketProbe
hsbt Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/zjit-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
rustup install ${{ matrix.rust_version }} --profile minimal
rustup default ${{ matrix.rust_version }}

- uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11
- uses: taiki-e/install-action@b8cecb83565409bcc297b2df6e77f030b2a468d5 # v2.82.0
with:
tool: nextest@0.9
if: ${{ matrix.test_task == 'zjit-check' }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zjit-ubuntu.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ jobs:
ruby-version: '3.1'
bundler: none

- uses: taiki-e/install-action@15449e3094499af05d8d964a1c884208e4b8b595 # v2.81.11
- uses: taiki-e/install-action@b8cecb83565409bcc297b2df6e77f030b2a468d5 # v2.82.0
with:
tool: nextest@0.9
if: ${{ matrix.test_task == 'zjit-check' }}
Expand Down
14 changes: 7 additions & 7 deletions lib/bundler/mirror.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,18 +160,18 @@ def replies?(mirror)

def wait_for_writtable_socket(socket, address, timeout)
if IO.select(nil, [socket], nil, timeout)
probe_writtable_socket(socket, address)
probe_writtable_socket(socket)
else # TCP Handshake timed out, or there is something dropping packets
false
end
end

def probe_writtable_socket(socket, address)
socket.connect_nonblock(address)
rescue Errno::EISCONN
true
rescue StandardError # Connection failed
false
def probe_writtable_socket(socket)
# Check the pending error on the socket rather than calling
# +connect_nonblock+ a second time. On BSD-based systems such as macOS a
# second connect returns +EISCONN+ even when the asynchronous connect
# failed, which would make a down mirror look reachable.
socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int == 0
end
end
end
Expand Down
90 changes: 90 additions & 0 deletions lib/rubygems/compact_index_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# frozen_string_literal: true

##
# The CompactIndexClient fetches and parses the compact index files
# (names, versions and info/[gem]) served by a gem server, keeping a
# local cache so subsequent fetches only transfer what changed.
#
# This is an independent RubyGems port of Bundler::CompactIndexClient.
# Both implementations are intentionally kept separate so that changes
# on either side cannot affect the other; this one only depends on
# RubyGems itself.

class Gem::CompactIndexClient
SUPPORTED_DIGESTS = { "sha-256" => :SHA256 }.freeze
DEBUG_MUTEX = Thread::Mutex.new

# info returns an Array of INFO Arrays. Each INFO Array has the following indices:
INFO_NAME = 0
INFO_VERSION = 1
INFO_PLATFORM = 2
INFO_DEPS = 3
INFO_REQS = 4

def self.debug
return unless ENV["DEBUG_COMPACT_INDEX"]
DEBUG_MUTEX.synchronize { warn("[#{self}] #{yield}") }
end

class Error < StandardError; end

require_relative "compact_index_client/cache"
require_relative "compact_index_client/cache_file"
require_relative "compact_index_client/http_fetcher"
require_relative "compact_index_client/parser"
require_relative "compact_index_client/updater"

# The client is instantiated with:
# - `directory`: the root directory where the cache files are stored.
# - `fetcher`: (optional) an object that responds to #call(uri_path, headers)
# and returns a Gem::Net::HTTP response. When the fetcher is not provided,
# the client only reads cached files from disk.
def initialize(directory, fetcher = nil)
@cache = Cache.new(directory, fetcher)
@parser = Parser.new(@cache)
end

def names
Gem::CompactIndexClient.debug { "names" }
@parser.names
end

def versions
Gem::CompactIndexClient.debug { "versions" }
@parser.versions
end

def dependencies(names)
Gem::CompactIndexClient.debug { "dependencies(#{names})" }
names.map {|name| info(name) }
end

def info(name)
Gem::CompactIndexClient.debug { "info(#{name})" }
@parser.info(name)
end

# Fetches a single gem's info without consulting the versions index,
# using a conditional request to refresh the cached file. Useful when
# only a few gems are needed and the versions index download would
# dominate, as in gem install.
def fetch_info(name)
Gem::CompactIndexClient.debug { "fetch_info(#{name})" }
@parser.parse_info(@cache.fetch_info(name), name)
end

def latest_version(name)
Gem::CompactIndexClient.debug { "latest_version(#{name})" }
@parser.info(name).map {|d| Gem::Version.new(d[INFO_VERSION]) }.max
end

def available?
Gem::CompactIndexClient.debug { "available?" }
@parser.available?
end

def reset!
Gem::CompactIndexClient.debug { "reset!" }
@cache.reset!
end
end
107 changes: 107 additions & 0 deletions lib/rubygems/compact_index_client/cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

require "digest"
require "fileutils"
require "pathname" unless defined?(Pathname)
require "set"

class Gem::CompactIndexClient
# Calls the Updater to update the cached files on disk, reads the
# cached files and returns their contents.
class Cache
attr_reader :directory

def initialize(directory, fetcher = nil)
@directory = Pathname.new(directory).expand_path
@updater = Updater.new(fetcher) if fetcher
@mutex = Thread::Mutex.new
@endpoints = Set.new

@info_root = mkdir("info")
@special_characters_info_root = mkdir("info-special-characters")
@info_etag_root = mkdir("info-etags")
end

def names
fetch("names", names_path, names_etag_path)
end

def versions
fetch("versions", versions_path, versions_etag_path)
end

def info(name, remote_checksum = nil)
path = info_path(name)

if remote_checksum && remote_checksum != checksum_for_file(path)
fetch("info/#{name}", path, info_etag_path(name))
else
Gem::CompactIndexClient.debug { "update skipped info/#{name} (#{remote_checksum ? "versions index checksum matches local" : "versions index checksum is nil"})" }
read(path)
end
end

# Fetch a single gem's info file without consulting the versions
# index, refreshing the cached file with a conditional request.
def fetch_info(name)
fetch("info/#{name}", info_path(name), info_etag_path(name))
end

def reset!
@mutex.synchronize { @endpoints.clear }
end

private

def names_path = directory.join("names")
def names_etag_path = directory.join("names.etag")
def versions_path = directory.join("versions")
def versions_etag_path = directory.join("versions.etag")

def info_path(name)
name = name.to_s
if /[^a-z0-9_-]/.match?(name)
name += "-#{Digest::MD5.hexdigest(name).downcase}"
@special_characters_info_root.join(name)
else
@info_root.join(name)
end
end

def info_etag_path(name)
name = name.to_s
@info_etag_root.join("#{name}-#{Digest::MD5.hexdigest(name).downcase}")
end

def checksum_for_file(path)
return unless path.file?
Digest::MD5.file(path).hexdigest
end

def mkdir(name)
directory.join(name).tap do |dir|
FileUtils.mkdir_p(dir)
end
end

def fetch(remote_path, path, etag_path)
if already_fetched?(remote_path)
Gem::CompactIndexClient.debug { "already fetched #{remote_path}" }
else
Gem::CompactIndexClient.debug { "fetching #{remote_path}" }
@updater&.update(remote_path, path, etag_path)
end

read(path)
end

def already_fetched?(remote_path)
@mutex.synchronize { !@endpoints.add?(remote_path) }
end

def read(path)
return unless path.file?
path.read
end
end
end
141 changes: 141 additions & 0 deletions lib/rubygems/compact_index_client/cache_file.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# frozen_string_literal: true

require "digest"
require "fileutils"
require_relative "../package"

class Gem::CompactIndexClient
# write cache files in a way that is robust to concurrent modifications
# if digests are given, the checksums will be verified
class CacheFile
DEFAULT_FILE_MODE = 0o644
private_constant :DEFAULT_FILE_MODE

class Error < RuntimeError; end
class ClosedError < Error; end

class DigestMismatchError < Error
def initialize(digests, expected_digests)
super "Calculated checksums #{digests.inspect} did not match expected #{expected_digests.inspect}."
end
end

# Initialize with a copy of the original file, then yield the instance.
def self.copy(path, &block)
new(path) do |file|
file.initialize_digests

path.open("rb") do |s|
file.open {|f| IO.copy_stream(s, f) }
end

yield file
end
end

# Write data to a temp file, then replace the original file with it verifying the digests if given.
def self.write(path, data, digests = nil)
return unless data
new(path) do |file|
file.digests = digests
file.write(data)
end
end

attr_reader :original_path, :path

def initialize(original_path, &block)
@original_path = original_path
@perm = original_path.file? ? original_path.stat.mode : DEFAULT_FILE_MODE
@path = original_path.sub(/$/, ".#{$$}.tmp")
return unless block_given?
begin
yield self
ensure
close
end
end

def size
path.size
end

# initialize the digests using CompactIndexClient::SUPPORTED_DIGESTS, or a subset based on keys.
def initialize_digests(keys = nil)
@digests = keys ? SUPPORTED_DIGESTS.slice(*keys) : SUPPORTED_DIGESTS.dup
@digests.transform_values! {|algo_class| Digest(algo_class).new }
end

# reset the digests so they don't contain any previously read data
def reset_digests
@digests&.each_value(&:reset)
end

# set the digests that will be verified at the end
def digests=(expected_digests)
@expected_digests = expected_digests

if @expected_digests.nil?
@digests = nil
elsif @digests
@digests = @digests.slice(*@expected_digests.keys)
else
initialize_digests(@expected_digests.keys)
end
end

def digests?
@digests&.any?
end

# Open the temp file for writing, reusing original permissions, yielding the IO object.
def open(write_mode = "wb", perm = @perm, &block)
raise ClosedError, "Cannot reopen closed file" if @closed
path.open(write_mode, perm) do |f|
yield digests? ? Gem::Package::DigestIO.new(f, @digests) : f
end
end

# Returns false without appending when no digests since appending is too error prone to do without digests.
def append(data)
return false unless digests?
open("a") {|f| f.write data }
verify && commit
end

def write(data)
reset_digests
open {|f| f.write data }
commit!
end

def commit!
verify || raise(DigestMismatchError.new(@base64digests, @expected_digests))
commit
end

# Verify the digests, returning true on match, false on mismatch.
def verify
return true unless @expected_digests && digests?
@base64digests = @digests.transform_values!(&:base64digest)
@digests = nil
@base64digests.all? {|algo, digest| @expected_digests[algo] == digest }
end

# Replace the original file with the temp file without verifying digests.
# The file is permanently closed.
def commit
raise ClosedError, "Cannot commit closed file" if @closed
FileUtils.mv(path, original_path)
@closed = true
end

# Remove the temp file without replacing the original file.
# The file is permanently closed.
def close
return if @closed
FileUtils.remove_file(path) if @path&.file?
@closed = true
end
end
end
Loading