scint 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/FEATURES.md +13 -0
- data/README.md +216 -0
- data/bin/bundler-vs-scint +233 -0
- data/bin/scint +35 -0
- data/bin/scint-io-summary +46 -0
- data/bin/scint-syscall-trace +41 -0
- data/lib/bundler/setup.rb +5 -0
- data/lib/bundler.rb +168 -0
- data/lib/scint/cache/layout.rb +131 -0
- data/lib/scint/cache/metadata_store.rb +75 -0
- data/lib/scint/cache/prewarm.rb +192 -0
- data/lib/scint/cli/add.rb +85 -0
- data/lib/scint/cli/cache.rb +316 -0
- data/lib/scint/cli/exec.rb +150 -0
- data/lib/scint/cli/install.rb +1047 -0
- data/lib/scint/cli/remove.rb +60 -0
- data/lib/scint/cli.rb +77 -0
- data/lib/scint/commands/exec.rb +17 -0
- data/lib/scint/commands/install.rb +17 -0
- data/lib/scint/credentials.rb +153 -0
- data/lib/scint/debug/io_trace.rb +218 -0
- data/lib/scint/debug/sampler.rb +138 -0
- data/lib/scint/downloader/fetcher.rb +113 -0
- data/lib/scint/downloader/pool.rb +112 -0
- data/lib/scint/errors.rb +63 -0
- data/lib/scint/fs.rb +119 -0
- data/lib/scint/gem/extractor.rb +86 -0
- data/lib/scint/gem/package.rb +62 -0
- data/lib/scint/gemfile/dependency.rb +30 -0
- data/lib/scint/gemfile/editor.rb +93 -0
- data/lib/scint/gemfile/parser.rb +275 -0
- data/lib/scint/index/cache.rb +166 -0
- data/lib/scint/index/client.rb +301 -0
- data/lib/scint/index/parser.rb +142 -0
- data/lib/scint/installer/extension_builder.rb +264 -0
- data/lib/scint/installer/linker.rb +226 -0
- data/lib/scint/installer/planner.rb +140 -0
- data/lib/scint/installer/preparer.rb +207 -0
- data/lib/scint/lockfile/parser.rb +251 -0
- data/lib/scint/lockfile/writer.rb +178 -0
- data/lib/scint/platform.rb +71 -0
- data/lib/scint/progress.rb +579 -0
- data/lib/scint/resolver/provider.rb +230 -0
- data/lib/scint/resolver/resolver.rb +249 -0
- data/lib/scint/runtime/exec.rb +141 -0
- data/lib/scint/runtime/setup.rb +45 -0
- data/lib/scint/scheduler.rb +392 -0
- data/lib/scint/source/base.rb +46 -0
- data/lib/scint/source/git.rb +92 -0
- data/lib/scint/source/path.rb +70 -0
- data/lib/scint/source/rubygems.rb +79 -0
- data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
- data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
- data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
- data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
- data/lib/scint/vendor/pub_grub/package.rb +43 -0
- data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
- data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
- data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
- data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
- data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
- data/lib/scint/vendor/pub_grub/term.rb +105 -0
- data/lib/scint/vendor/pub_grub/version.rb +3 -0
- data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
- data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
- data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
- data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
- data/lib/scint/vendor/pub_grub.rb +32 -0
- data/lib/scint/worker_pool.rb +114 -0
- data/lib/scint.rb +87 -0
- metadata +116 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "zlib"
|
|
6
|
+
require_relative "cache"
|
|
7
|
+
require_relative "parser"
|
|
8
|
+
|
|
9
|
+
module Scint
|
|
10
|
+
module Index
|
|
11
|
+
# Compact index client for rubygems.org (or any compact index source).
|
|
12
|
+
# Thread-safe. Uses ETag/Range for efficient updates.
|
|
13
|
+
class Client
|
|
14
|
+
ACCEPT_ENCODING = "gzip"
|
|
15
|
+
USER_AGENT = "scint/0.1.0"
|
|
16
|
+
DEFAULT_TIMEOUT = 15
|
|
17
|
+
|
|
18
|
+
attr_reader :source_uri
|
|
19
|
+
|
|
20
|
+
def initialize(source_uri, cache_dir: nil, credentials: nil)
|
|
21
|
+
@source_uri = source_uri.to_s.chomp("/")
|
|
22
|
+
@uri = URI.parse(@source_uri)
|
|
23
|
+
@cache = Cache.new(cache_dir || default_cache_dir)
|
|
24
|
+
@parser = Parser.new
|
|
25
|
+
@credentials = credentials
|
|
26
|
+
@mutex = Thread::Mutex.new
|
|
27
|
+
@fetched = {} # track which endpoints we've already fetched this session
|
|
28
|
+
@connections = Thread::Queue.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetch the list of all gem names from this source.
|
|
32
|
+
def fetch_names
|
|
33
|
+
data = fetch_endpoint("names")
|
|
34
|
+
@parser.parse_names(data)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetch the versions list. Returns { name => [[name, version, platform], ...] }
|
|
38
|
+
# Also populates info checksums for cache validation.
|
|
39
|
+
def fetch_versions
|
|
40
|
+
data = fetch_endpoint("versions")
|
|
41
|
+
@parser.parse_versions(data)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Fetch info for a single gem. Returns parsed info entries.
|
|
45
|
+
# Uses binary cache when checksum matches.
|
|
46
|
+
def fetch_info(gem_name)
|
|
47
|
+
checksums = @parser.info_checksums
|
|
48
|
+
checksum = checksums[gem_name]
|
|
49
|
+
|
|
50
|
+
# Try binary cache first
|
|
51
|
+
if checksum && !checksum.empty?
|
|
52
|
+
cached = @cache.read_binary_info(gem_name, checksum)
|
|
53
|
+
return cached if cached
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Check if local info file matches remote checksum
|
|
57
|
+
if checksum && !checksum.empty? && @cache.info_fresh?(gem_name, checksum)
|
|
58
|
+
data = @cache.info(gem_name)
|
|
59
|
+
else
|
|
60
|
+
data = fetch_info_endpoint(gem_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return [] unless data
|
|
64
|
+
|
|
65
|
+
result = @parser.parse_info(gem_name, data)
|
|
66
|
+
|
|
67
|
+
# Write binary cache
|
|
68
|
+
if checksum && !checksum.empty? && !result.empty?
|
|
69
|
+
@cache.write_binary_info(gem_name, checksum, result)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Prefetch info for multiple gems concurrently.
|
|
76
|
+
# Uses a thread pool for parallel HTTP requests.
|
|
77
|
+
def prefetch(gem_names, worker_count: nil)
|
|
78
|
+
names = Array(gem_names).uniq
|
|
79
|
+
return if names.empty?
|
|
80
|
+
|
|
81
|
+
# Ensure versions are fetched first (populates checksums)
|
|
82
|
+
fetch_versions unless @parser.info_checksums.any?
|
|
83
|
+
|
|
84
|
+
# Filter to names that actually need fetching
|
|
85
|
+
checksums = @parser.info_checksums
|
|
86
|
+
to_fetch = names.select do |name|
|
|
87
|
+
checksum = checksums[name]
|
|
88
|
+
if checksum && !checksum.empty?
|
|
89
|
+
cached = @cache.read_binary_info(name, checksum)
|
|
90
|
+
!cached
|
|
91
|
+
else
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
end.reject do |name|
|
|
95
|
+
checksum = checksums[name]
|
|
96
|
+
checksum && !checksum.empty? && @cache.info_fresh?(name, checksum)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return if to_fetch.empty?
|
|
100
|
+
|
|
101
|
+
pool_size = worker_count || [to_fetch.size, 8].min
|
|
102
|
+
results = {}
|
|
103
|
+
queue = Thread::Queue.new
|
|
104
|
+
to_fetch.each { |n| queue.push(n) }
|
|
105
|
+
pool_size.times { queue.push(:done) }
|
|
106
|
+
|
|
107
|
+
threads = pool_size.times.map do
|
|
108
|
+
Thread.new do
|
|
109
|
+
while (name = queue.pop) != :done
|
|
110
|
+
begin
|
|
111
|
+
data = fetch_info_endpoint(name)
|
|
112
|
+
if data
|
|
113
|
+
parsed = @parser.parse_info(name, data)
|
|
114
|
+
checksum = checksums[name]
|
|
115
|
+
if checksum && !checksum.empty? && !parsed.empty?
|
|
116
|
+
@cache.write_binary_info(name, checksum, parsed)
|
|
117
|
+
end
|
|
118
|
+
@mutex.synchronize { results[name] = parsed }
|
|
119
|
+
end
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
$stderr.puts "prefetch warning: #{name}: #{e.message}" if ENV["SCINT_DEBUG"]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
threads.each(&:join)
|
|
128
|
+
results
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Shut down any pooled connections.
|
|
132
|
+
def close
|
|
133
|
+
while !@connections.empty?
|
|
134
|
+
begin
|
|
135
|
+
conn = @connections.pop(true)
|
|
136
|
+
conn.finish if conn.started?
|
|
137
|
+
rescue StandardError
|
|
138
|
+
# ignore
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def default_cache_dir
|
|
146
|
+
xdg = ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache")
|
|
147
|
+
File.join(xdg, "scint", "index", Cache.slug_for(@uri))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Fetch a top-level endpoint (names or versions).
|
|
151
|
+
# Uses ETag for conditional requests and Range for incremental versions updates.
|
|
152
|
+
def fetch_endpoint(endpoint)
|
|
153
|
+
@mutex.synchronize do
|
|
154
|
+
return @fetched[endpoint] if @fetched.key?(endpoint)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
if endpoint == "versions"
|
|
158
|
+
data = fetch_versions_with_range
|
|
159
|
+
else
|
|
160
|
+
etag = @cache.names_etag
|
|
161
|
+
response = http_get("#{@source_uri}/#{endpoint}", etag: etag)
|
|
162
|
+
|
|
163
|
+
case response
|
|
164
|
+
when Net::HTTPNotModified
|
|
165
|
+
data = @cache.names
|
|
166
|
+
when Net::HTTPSuccess
|
|
167
|
+
data = decode_body(response)
|
|
168
|
+
@cache.write_names(data, etag: extract_etag(response))
|
|
169
|
+
else
|
|
170
|
+
raise NetworkError, "Failed to fetch #{endpoint}: HTTP #{response.code}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
@mutex.synchronize { @fetched[endpoint] = data }
|
|
175
|
+
data
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def fetch_versions_with_range
|
|
179
|
+
etag = @cache.versions_etag
|
|
180
|
+
local_size = @cache.versions_size
|
|
181
|
+
|
|
182
|
+
if local_size > 0
|
|
183
|
+
# Try range request (subtract 1 byte for overlap)
|
|
184
|
+
response = http_get("#{@source_uri}/versions", etag: etag, range_start: local_size - 1)
|
|
185
|
+
|
|
186
|
+
case response
|
|
187
|
+
when Net::HTTPNotModified
|
|
188
|
+
return @cache.versions
|
|
189
|
+
when Net::HTTPPartialContent
|
|
190
|
+
body = decode_body(response)
|
|
191
|
+
# Skip the overlapping byte
|
|
192
|
+
tail = body.byteslice(1..)
|
|
193
|
+
if tail && !tail.empty?
|
|
194
|
+
@cache.write_versions(tail, etag: extract_etag(response), append: true)
|
|
195
|
+
end
|
|
196
|
+
return @cache.versions
|
|
197
|
+
when Net::HTTPSuccess
|
|
198
|
+
# Server ignored range, gave us full response
|
|
199
|
+
data = decode_body(response)
|
|
200
|
+
@cache.write_versions(data, etag: extract_etag(response))
|
|
201
|
+
return data
|
|
202
|
+
when Net::HTTPRequestedRangeNotSatisfiable
|
|
203
|
+
# Fall through to full fetch
|
|
204
|
+
else
|
|
205
|
+
raise NetworkError, "Failed to fetch versions (range): HTTP #{response.code}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Full fetch
|
|
210
|
+
response = http_get("#{@source_uri}/versions", etag: etag)
|
|
211
|
+
case response
|
|
212
|
+
when Net::HTTPNotModified
|
|
213
|
+
@cache.versions
|
|
214
|
+
when Net::HTTPSuccess
|
|
215
|
+
data = decode_body(response)
|
|
216
|
+
@cache.write_versions(data, etag: extract_etag(response))
|
|
217
|
+
data
|
|
218
|
+
else
|
|
219
|
+
raise NetworkError, "Failed to fetch versions: HTTP #{response.code}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Fetch info endpoint for a single gem.
|
|
224
|
+
def fetch_info_endpoint(gem_name)
|
|
225
|
+
etag = @cache.info_etag(gem_name)
|
|
226
|
+
response = http_get("#{@source_uri}/info/#{gem_name}", etag: etag)
|
|
227
|
+
|
|
228
|
+
case response
|
|
229
|
+
when Net::HTTPNotModified
|
|
230
|
+
@cache.info(gem_name)
|
|
231
|
+
when Net::HTTPSuccess
|
|
232
|
+
data = decode_body(response)
|
|
233
|
+
@cache.write_info(gem_name, data, etag: extract_etag(response))
|
|
234
|
+
data
|
|
235
|
+
when Net::HTTPNotFound
|
|
236
|
+
nil
|
|
237
|
+
else
|
|
238
|
+
raise NetworkError, "Failed to fetch info/#{gem_name}: HTTP #{response.code}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def http_get(url, etag: nil, range_start: nil)
|
|
243
|
+
uri = URI.parse(url)
|
|
244
|
+
conn = checkout_connection(uri)
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
248
|
+
request["User-Agent"] = USER_AGENT
|
|
249
|
+
request["Accept-Encoding"] = ACCEPT_ENCODING
|
|
250
|
+
request["If-None-Match"] = %("#{etag}") if etag
|
|
251
|
+
request["Range"] = "bytes=#{range_start}-" if range_start
|
|
252
|
+
@credentials&.apply!(request, uri)
|
|
253
|
+
|
|
254
|
+
conn.request(request)
|
|
255
|
+
ensure
|
|
256
|
+
checkin_connection(conn)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def checkout_connection(uri)
|
|
261
|
+
begin
|
|
262
|
+
conn = @connections.pop(true)
|
|
263
|
+
return conn if conn.started?
|
|
264
|
+
rescue ThreadError
|
|
265
|
+
# Queue empty
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
conn = Net::HTTP.new(uri.host, uri.port)
|
|
269
|
+
conn.use_ssl = (uri.scheme == "https")
|
|
270
|
+
conn.open_timeout = DEFAULT_TIMEOUT
|
|
271
|
+
conn.read_timeout = DEFAULT_TIMEOUT
|
|
272
|
+
conn.start
|
|
273
|
+
conn
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def checkin_connection(conn)
|
|
277
|
+
@connections.push(conn) if conn.started?
|
|
278
|
+
rescue StandardError
|
|
279
|
+
# ignore
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def decode_body(response)
|
|
283
|
+
body = response.body
|
|
284
|
+
return body unless body
|
|
285
|
+
|
|
286
|
+
if response["Content-Encoding"] == "gzip"
|
|
287
|
+
Zlib::GzipReader.new(StringIO.new(body)).read
|
|
288
|
+
else
|
|
289
|
+
body
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def extract_etag(response)
|
|
294
|
+
return nil unless response["ETag"]
|
|
295
|
+
etag = response["ETag"].delete_prefix("W/")
|
|
296
|
+
etag = etag.delete_prefix('"').delete_suffix('"')
|
|
297
|
+
etag.empty? ? nil : etag
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scint
|
|
4
|
+
module Index
|
|
5
|
+
class Parser
|
|
6
|
+
# Parse the compact index "names" endpoint response.
|
|
7
|
+
# Returns array of gem name strings.
|
|
8
|
+
def parse_names(data)
|
|
9
|
+
return [] if data.nil? || data.empty?
|
|
10
|
+
lines = strip_header(data)
|
|
11
|
+
lines
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Parse the compact index "versions" endpoint response.
|
|
15
|
+
# Returns a hash:
|
|
16
|
+
# { name => [[name, version, platform], ...], ... }
|
|
17
|
+
# Also stores checksums accessible via #info_checksums.
|
|
18
|
+
def parse_versions(data)
|
|
19
|
+
@versions_by_name = Hash.new { |h, k| h[k] = [] }
|
|
20
|
+
@info_checksums = {}
|
|
21
|
+
|
|
22
|
+
return @versions_by_name if data.nil? || data.empty?
|
|
23
|
+
|
|
24
|
+
strip_header(data).each do |line|
|
|
25
|
+
line.freeze
|
|
26
|
+
|
|
27
|
+
name_end = line.index(" ")
|
|
28
|
+
next unless name_end
|
|
29
|
+
|
|
30
|
+
versions_end = line.index(" ", name_end + 1)
|
|
31
|
+
name = line[0, name_end].freeze
|
|
32
|
+
|
|
33
|
+
if versions_end
|
|
34
|
+
versions_string = line[name_end + 1, versions_end - name_end - 1]
|
|
35
|
+
@info_checksums[name] = line[versions_end + 1, line.size - versions_end - 1]
|
|
36
|
+
else
|
|
37
|
+
versions_string = line[name_end + 1, line.size - name_end - 1]
|
|
38
|
+
@info_checksums[name] = ""
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
versions_string.split(",") do |version|
|
|
42
|
+
delete = version.delete_prefix!("-")
|
|
43
|
+
parts = version.split("-", 2)
|
|
44
|
+
entry = parts.unshift(name)
|
|
45
|
+
if delete
|
|
46
|
+
@versions_by_name[name].delete(entry)
|
|
47
|
+
else
|
|
48
|
+
@versions_by_name[name] << entry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@versions_by_name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns checksums collected during parse_versions.
|
|
57
|
+
# Keys are gem names, values are checksum strings.
|
|
58
|
+
def info_checksums
|
|
59
|
+
@info_checksums || {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Parse a compact index "info/{gem_name}" endpoint response.
|
|
63
|
+
# Returns array of entries:
|
|
64
|
+
# [name, version, platform, deps_hash, requirements_hash]
|
|
65
|
+
#
|
|
66
|
+
# deps_hash: { "dep_name" => "version_constraints", ... }
|
|
67
|
+
# requirements_hash: { "ruby" => ">= 2.7", "rubygems" => ">= 3.0" }
|
|
68
|
+
def parse_info(name, data)
|
|
69
|
+
return [] if data.nil? || data.empty?
|
|
70
|
+
|
|
71
|
+
strip_header(data).map do |line|
|
|
72
|
+
parse_info_line(name, line)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Strip the "---\n" header that compact index responses may include.
|
|
79
|
+
def strip_header(data)
|
|
80
|
+
lines = data.split("\n")
|
|
81
|
+
header = lines.index("---")
|
|
82
|
+
header ? lines[(header + 1)..] : lines
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parse a single info line using the compact index format:
|
|
86
|
+
# VERSION[-PLATFORM] DEP1:REQ1&REQ2,DEP2:REQ3|RUBY_REQ:VAL1&VAL2,RUBYGEMS_REQ:VAL3
|
|
87
|
+
#
|
|
88
|
+
# - Space separates version from the rest
|
|
89
|
+
# - "|" separates dependency list from requirement list
|
|
90
|
+
# - "," separates individual deps or reqs
|
|
91
|
+
# - ":" separates name from version constraints within each dep/req
|
|
92
|
+
# - "&" separates multiple version constraints for one dep/req
|
|
93
|
+
def parse_info_line(name, line)
|
|
94
|
+
version_and_platform, rest = line.split(" ", 2)
|
|
95
|
+
|
|
96
|
+
# Split version and platform
|
|
97
|
+
version, platform = version_and_platform.split("-", 2)
|
|
98
|
+
platform ||= "ruby"
|
|
99
|
+
|
|
100
|
+
# Split rest into deps and requirements by "|"
|
|
101
|
+
deps = {}
|
|
102
|
+
reqs = {}
|
|
103
|
+
|
|
104
|
+
if rest && !rest.empty?
|
|
105
|
+
deps_str, reqs_str = rest.split("|", 2)
|
|
106
|
+
|
|
107
|
+
# Parse dependencies
|
|
108
|
+
if deps_str && !deps_str.empty?
|
|
109
|
+
deps_str.split(",").each do |dep_entry|
|
|
110
|
+
parts = dep_entry.split(":")
|
|
111
|
+
dep_name = parts[0]
|
|
112
|
+
next if dep_name.nil? || dep_name.empty?
|
|
113
|
+
dep_name = -dep_name # freeze and deduplicate
|
|
114
|
+
if parts.size > 1
|
|
115
|
+
deps[dep_name] = parts[1].split("&").join(", ")
|
|
116
|
+
else
|
|
117
|
+
deps[dep_name] = ">= 0"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Parse requirements (ruby, rubygems version constraints)
|
|
123
|
+
if reqs_str && !reqs_str.empty?
|
|
124
|
+
reqs_str.split(",").each do |req_entry|
|
|
125
|
+
parts = req_entry.split(":")
|
|
126
|
+
req_name = parts[0]
|
|
127
|
+
next if req_name.nil? || req_name.empty?
|
|
128
|
+
req_name = -req_name.strip
|
|
129
|
+
if parts.size > 1
|
|
130
|
+
reqs[req_name] = parts[1].split("&").join(", ")
|
|
131
|
+
else
|
|
132
|
+
reqs[req_name] = ">= 0"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
[name, version, platform, deps, reqs]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../fs"
|
|
4
|
+
require_relative "../platform"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require "open3"
|
|
7
|
+
|
|
8
|
+
module Scint
|
|
9
|
+
module Installer
|
|
10
|
+
module ExtensionBuilder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Build native extensions for a prepared gem.
|
|
14
|
+
# prepared_gem: PreparedGem struct
|
|
15
|
+
# bundle_path: .bundle/ root
|
|
16
|
+
# abi_key: e.g. "ruby-3.3.0-arm64-darwin24" (defaults to Platform.abi_key)
|
|
17
|
+
def build(prepared_gem, bundle_path, cache_layout, abi_key: Platform.abi_key, compile_slots: 1, output_tail: nil)
|
|
18
|
+
spec = prepared_gem.spec
|
|
19
|
+
ruby_dir = ruby_install_dir(bundle_path)
|
|
20
|
+
build_ruby_dir = cache_layout.install_ruby_dir
|
|
21
|
+
|
|
22
|
+
# Check global extension cache first
|
|
23
|
+
cached_ext = cache_layout.ext_path(spec, abi_key)
|
|
24
|
+
if Dir.exist?(cached_ext) && File.exist?(File.join(cached_ext, "gem.build_complete"))
|
|
25
|
+
link_extensions(cached_ext, ruby_dir, spec, abi_key)
|
|
26
|
+
return true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build in a temp dir, then cache
|
|
30
|
+
src_dir = prepared_gem.extracted_path
|
|
31
|
+
ext_dirs = find_extension_dirs(src_dir)
|
|
32
|
+
raise ExtensionBuildError, "No extension directories found for #{spec.name}" if ext_dirs.empty?
|
|
33
|
+
|
|
34
|
+
FS.with_tempdir("scint-ext") do |tmpdir|
|
|
35
|
+
build_dir = File.join(tmpdir, "build")
|
|
36
|
+
install_dir = File.join(tmpdir, "install")
|
|
37
|
+
FS.mkdir_p(build_dir)
|
|
38
|
+
FS.mkdir_p(install_dir)
|
|
39
|
+
|
|
40
|
+
ext_dirs.each do |ext_dir|
|
|
41
|
+
compile_extension(ext_dir, build_dir, install_dir, src_dir, spec, build_ruby_dir, compile_slots, output_tail)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Write marker
|
|
45
|
+
File.write(File.join(install_dir, "gem.build_complete"), "")
|
|
46
|
+
|
|
47
|
+
# Cache globally
|
|
48
|
+
FS.mkdir_p(File.dirname(cached_ext))
|
|
49
|
+
FS.atomic_move(install_dir, cached_ext)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
link_extensions(cached_ext, ruby_dir, spec, abi_key)
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# True when a completed global extension build exists for this spec + ABI.
|
|
57
|
+
def cached_build_available?(spec, cache_layout, abi_key: Platform.abi_key)
|
|
58
|
+
cached_ext = cache_layout.ext_path(spec, abi_key)
|
|
59
|
+
Dir.exist?(cached_ext) && File.exist?(File.join(cached_ext, "gem.build_complete"))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Link already-compiled extensions from global cache into bundle_path.
|
|
63
|
+
# Returns true when cache was present and linked, false otherwise.
|
|
64
|
+
def link_cached_build(prepared_gem, bundle_path, cache_layout, abi_key: Platform.abi_key)
|
|
65
|
+
spec = prepared_gem.spec
|
|
66
|
+
return false unless cached_build_available?(spec, cache_layout, abi_key: abi_key)
|
|
67
|
+
|
|
68
|
+
ruby_dir = ruby_install_dir(bundle_path)
|
|
69
|
+
cached_ext = cache_layout.ext_path(spec, abi_key)
|
|
70
|
+
link_extensions(cached_ext, ruby_dir, spec, abi_key)
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- private ---
|
|
75
|
+
|
|
76
|
+
def buildable_source_dir?(gem_dir)
|
|
77
|
+
find_extension_dirs(gem_dir).any?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def find_extension_dirs(gem_dir)
|
|
81
|
+
dirs = []
|
|
82
|
+
|
|
83
|
+
# extconf.rb in ext/ subdirectories
|
|
84
|
+
Dir.glob(File.join(gem_dir, "ext", "**", "extconf.rb")).each do |path|
|
|
85
|
+
dirs << File.dirname(path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# CMakeLists.txt in ext/
|
|
89
|
+
Dir.glob(File.join(gem_dir, "ext", "**", "CMakeLists.txt")).each do |path|
|
|
90
|
+
dir = File.dirname(path)
|
|
91
|
+
dirs << dir unless dirs.include?(dir)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Rakefile-based (look for Rakefile in ext/)
|
|
95
|
+
if dirs.empty?
|
|
96
|
+
Dir.glob(File.join(gem_dir, "ext", "**", "Rakefile")).each do |path|
|
|
97
|
+
dirs << File.dirname(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
dirs.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compile_extension(ext_dir, build_dir, install_dir, gem_dir, spec, build_ruby_dir, compile_slots, output_tail = nil)
|
|
105
|
+
make_jobs = adaptive_make_jobs(compile_slots)
|
|
106
|
+
env = build_env(gem_dir, build_ruby_dir, make_jobs)
|
|
107
|
+
|
|
108
|
+
if File.exist?(File.join(ext_dir, "extconf.rb"))
|
|
109
|
+
compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
|
|
110
|
+
elsif File.exist?(File.join(ext_dir, "CMakeLists.txt"))
|
|
111
|
+
compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail)
|
|
112
|
+
elsif File.exist?(File.join(ext_dir, "Rakefile"))
|
|
113
|
+
compile_rake(ext_dir, build_dir, install_dir, build_ruby_dir, env, output_tail)
|
|
114
|
+
else
|
|
115
|
+
raise ExtensionBuildError, "No known build system in #{ext_dir}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def compile_extconf(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
|
|
120
|
+
run_cmd(env, RbConfig.ruby, File.join(ext_dir, "extconf.rb"),
|
|
121
|
+
"--with-opt-dir=#{RbConfig::CONFIG["prefix"]}",
|
|
122
|
+
chdir: build_dir, output_tail: output_tail)
|
|
123
|
+
run_cmd(env, "make", "-j#{make_jobs}", "-C", build_dir, output_tail: output_tail)
|
|
124
|
+
run_cmd(env, "make", "install", "DESTDIR=", "sitearchdir=#{install_dir}", "sitelibdir=#{install_dir}",
|
|
125
|
+
chdir: build_dir, output_tail: output_tail)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def compile_cmake(ext_dir, build_dir, install_dir, env, make_jobs, output_tail = nil)
|
|
129
|
+
run_cmd(env, "cmake", ext_dir, "-B", build_dir,
|
|
130
|
+
"-DCMAKE_INSTALL_PREFIX=#{install_dir}", output_tail: output_tail)
|
|
131
|
+
run_cmd(env, "cmake", "--build", build_dir, "--parallel", make_jobs.to_s, output_tail: output_tail)
|
|
132
|
+
run_cmd(env, "cmake", "--install", build_dir, output_tail: output_tail)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def compile_rake(ext_dir, build_dir, install_dir, ruby_dir, env, output_tail = nil)
|
|
136
|
+
rake_exe = find_rake_executable(ruby_dir)
|
|
137
|
+
begin
|
|
138
|
+
if rake_exe
|
|
139
|
+
run_cmd(env, RbConfig.ruby, rake_exe, "compile",
|
|
140
|
+
chdir: ext_dir, output_tail: output_tail)
|
|
141
|
+
else
|
|
142
|
+
run_cmd(env, RbConfig.ruby, "-S", "rake", "compile",
|
|
143
|
+
chdir: ext_dir, output_tail: output_tail)
|
|
144
|
+
end
|
|
145
|
+
rescue ExtensionBuildError => e
|
|
146
|
+
# Some gems ship a Rakefile but do not expose a compile task.
|
|
147
|
+
# Treat this as "nothing to build" rather than a hard failure.
|
|
148
|
+
raise unless e.message.include?("Don't know how to build task 'compile'")
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
# Copy built artifacts to install_dir
|
|
152
|
+
Dir.glob(File.join(ext_dir, "**", "*.{so,bundle,dll,dylib}")).each do |so|
|
|
153
|
+
FileUtils.cp(so, install_dir)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find_rake_executable(ruby_dir)
|
|
158
|
+
gems_dir = File.join(ruby_dir, "gems")
|
|
159
|
+
return nil unless Dir.exist?(gems_dir)
|
|
160
|
+
|
|
161
|
+
# Prefer highest installed rake version.
|
|
162
|
+
rake_dirs = Dir.glob(File.join(gems_dir, "rake-*")).sort.reverse
|
|
163
|
+
rake_dirs.each do |dir|
|
|
164
|
+
%w[exe bin].each do |subdir|
|
|
165
|
+
path = File.join(dir, subdir, "rake")
|
|
166
|
+
return path if File.file?(path)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def link_extensions(cached_ext, ruby_dir, spec, abi_key)
|
|
173
|
+
ext_install_dir = File.join(ruby_dir, "extensions",
|
|
174
|
+
Platform.gem_arch, Platform.extension_api_version,
|
|
175
|
+
spec_full_name(spec))
|
|
176
|
+
return if Dir.exist?(ext_install_dir)
|
|
177
|
+
|
|
178
|
+
FS.hardlink_tree(cached_ext, ext_install_dir)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_env(gem_dir, build_ruby_dir, make_jobs)
|
|
182
|
+
ruby_bin = File.join(build_ruby_dir, "bin")
|
|
183
|
+
path = [ruby_bin, ENV["PATH"]].compact.reject(&:empty?).join(File::PATH_SEPARATOR)
|
|
184
|
+
{
|
|
185
|
+
"GEM_HOME" => build_ruby_dir,
|
|
186
|
+
"GEM_PATH" => build_ruby_dir,
|
|
187
|
+
"BUNDLE_PATH" => build_ruby_dir,
|
|
188
|
+
"BUNDLE_GEMFILE" => "",
|
|
189
|
+
"MAKEFLAGS" => "-j#{make_jobs}",
|
|
190
|
+
"PATH" => path,
|
|
191
|
+
"CFLAGS" => "-I#{RbConfig::CONFIG["rubyhdrdir"]} -I#{RbConfig::CONFIG["rubyarchhdrdir"]}",
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def adaptive_make_jobs(compile_slots)
|
|
196
|
+
slots = [compile_slots.to_i, 1].max
|
|
197
|
+
jobs = Platform.cpu_count / slots
|
|
198
|
+
[jobs, 1].max
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def run_cmd(env, *cmd, chdir: nil, output_tail: nil)
|
|
202
|
+
opts = { chdir: chdir }.compact
|
|
203
|
+
|
|
204
|
+
if ENV["SCINT_DEBUG"]
|
|
205
|
+
pid = Process.spawn(env, *cmd, **opts)
|
|
206
|
+
_, status = Process.wait2(pid)
|
|
207
|
+
unless status.success?
|
|
208
|
+
raise ExtensionBuildError,
|
|
209
|
+
"Command failed (exit #{status.exitstatus}): #{cmd.join(" ")}"
|
|
210
|
+
end
|
|
211
|
+
return
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Stream output line-by-line so the UX gets live compile progress
|
|
215
|
+
# instead of waiting for the entire subprocess to finish.
|
|
216
|
+
all_output = +""
|
|
217
|
+
tail_lines = []
|
|
218
|
+
cmd_label = "$ #{cmd.join(" ")}"
|
|
219
|
+
|
|
220
|
+
Open3.popen2e(env, *cmd, **opts) do |stdin, out_err, wait_thr|
|
|
221
|
+
stdin.close
|
|
222
|
+
|
|
223
|
+
out_err.each_line do |line|
|
|
224
|
+
stripped = line.rstrip
|
|
225
|
+
all_output << line
|
|
226
|
+
next if stripped.empty?
|
|
227
|
+
|
|
228
|
+
tail_lines << stripped
|
|
229
|
+
tail_lines.shift if tail_lines.length > 5
|
|
230
|
+
|
|
231
|
+
if output_tail
|
|
232
|
+
output_tail.call([cmd_label, *tail_lines])
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
status = wait_thr.value
|
|
237
|
+
unless status.success?
|
|
238
|
+
details = all_output.strip
|
|
239
|
+
message = "Command failed (exit #{status.exitstatus}): #{cmd.join(" ")}"
|
|
240
|
+
message = "#{message}\n#{details}" unless details.empty?
|
|
241
|
+
raise ExtensionBuildError, message
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def spec_full_name(spec)
|
|
247
|
+
name = spec.name
|
|
248
|
+
version = spec.version
|
|
249
|
+
plat = spec.respond_to?(:platform) ? spec.platform : nil
|
|
250
|
+
base = "#{name}-#{version}"
|
|
251
|
+
(plat.nil? || plat.to_s == "ruby" || plat.to_s.empty?) ? base : "#{base}-#{plat}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def ruby_install_dir(bundle_path)
|
|
255
|
+
File.join(bundle_path, "ruby", RUBY_VERSION.split(".")[0, 2].join(".") + ".0")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private_class_method :find_extension_dirs, :compile_extension,
|
|
259
|
+
:compile_extconf, :compile_cmake, :compile_rake,
|
|
260
|
+
:find_rake_executable, :link_extensions, :build_env, :run_cmd,
|
|
261
|
+
:spec_full_name, :ruby_install_dir
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|