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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
require_relative "../gemfile/editor"
|
|
5
|
+
require_relative "install"
|
|
6
|
+
|
|
7
|
+
module Scint
|
|
8
|
+
module CLI
|
|
9
|
+
class Remove
|
|
10
|
+
def initialize(argv = [])
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@skip_install = false
|
|
13
|
+
parse_options
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
if @gems.empty?
|
|
18
|
+
$stderr.puts "Usage: scint remove GEM [GEM...] [--skip-install]"
|
|
19
|
+
return 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
editor = Gemfile::Editor.new("Gemfile")
|
|
23
|
+
|
|
24
|
+
@gems.each do |gem_name|
|
|
25
|
+
if editor.remove(gem_name)
|
|
26
|
+
$stdout.puts "Removed #{gem_name} from Gemfile"
|
|
27
|
+
else
|
|
28
|
+
$stdout.puts "No Gemfile entry found for #{gem_name}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
return 0 if @skip_install
|
|
33
|
+
|
|
34
|
+
CLI::Install.new([]).run
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_options
|
|
40
|
+
@gems = []
|
|
41
|
+
i = 0
|
|
42
|
+
while i < @argv.length
|
|
43
|
+
token = @argv[i]
|
|
44
|
+
|
|
45
|
+
case token
|
|
46
|
+
when "--skip-install"
|
|
47
|
+
@skip_install = true
|
|
48
|
+
i += 1
|
|
49
|
+
else
|
|
50
|
+
if token.start_with?("-")
|
|
51
|
+
raise GemfileError, "Unknown option for remove: #{token}"
|
|
52
|
+
end
|
|
53
|
+
@gems << token
|
|
54
|
+
i += 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/scint/cli.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Scint
|
|
4
|
+
module CLI
|
|
5
|
+
COMMANDS = %w[install add remove exec cache version help].freeze
|
|
6
|
+
|
|
7
|
+
def self.run(argv)
|
|
8
|
+
argv = argv.dup
|
|
9
|
+
command = argv.shift || "install"
|
|
10
|
+
|
|
11
|
+
case command
|
|
12
|
+
when "install", "i"
|
|
13
|
+
require_relative "cli/install"
|
|
14
|
+
CLI::Install.new(argv).run
|
|
15
|
+
when "add"
|
|
16
|
+
require_relative "cli/add"
|
|
17
|
+
CLI::Add.new(argv).run
|
|
18
|
+
when "remove", "rm"
|
|
19
|
+
require_relative "cli/remove"
|
|
20
|
+
CLI::Remove.new(argv).run
|
|
21
|
+
when "exec", "e"
|
|
22
|
+
require_relative "cli/exec"
|
|
23
|
+
CLI::Exec.new(argv).run
|
|
24
|
+
when "cache", "c"
|
|
25
|
+
require_relative "cli/cache"
|
|
26
|
+
CLI::Cache.new(argv).run
|
|
27
|
+
when "version", "-v", "--version"
|
|
28
|
+
$stdout.puts "scint #{Scint::VERSION}"
|
|
29
|
+
0
|
|
30
|
+
when "help", "-h", "--help"
|
|
31
|
+
print_help
|
|
32
|
+
0
|
|
33
|
+
else
|
|
34
|
+
$stderr.puts "Unknown command: #{command}"
|
|
35
|
+
$stderr.puts "Run 'scint help' for usage."
|
|
36
|
+
1
|
|
37
|
+
end
|
|
38
|
+
rescue BundlerError => e
|
|
39
|
+
$stderr.puts "Error: #{e.message}"
|
|
40
|
+
e.status_code
|
|
41
|
+
rescue Interrupt
|
|
42
|
+
$stderr.puts "\nInterrupted"
|
|
43
|
+
130
|
|
44
|
+
rescue => e
|
|
45
|
+
$stderr.puts "Fatal: #{e.class}: #{e.message}"
|
|
46
|
+
$stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
|
|
47
|
+
1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.print_help
|
|
51
|
+
$stdout.puts <<~HELP
|
|
52
|
+
Usage: scint COMMAND [OPTIONS]
|
|
53
|
+
|
|
54
|
+
Commands:
|
|
55
|
+
install Install gems from Gemfile (default)
|
|
56
|
+
add Add gem(s) to Gemfile and install
|
|
57
|
+
remove Remove gem(s) from Gemfile and install
|
|
58
|
+
exec Execute a command in the bundle context
|
|
59
|
+
cache Manage scint cache (list/clear/dir)
|
|
60
|
+
version Print version
|
|
61
|
+
help Show this help
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--jobs N Number of parallel workers (default: auto)
|
|
65
|
+
--path P Install gems to path
|
|
66
|
+
--verbose Verbose output
|
|
67
|
+
--force Reinstall all gems, ignoring cache and local bundle state
|
|
68
|
+
|
|
69
|
+
Debug ENV:
|
|
70
|
+
SCINT_PROFILE=/tmp/scint-profile.json Write Ruby sampling profile
|
|
71
|
+
SCINT_PROFILE_HZ=250 Sampler frequency
|
|
72
|
+
SCINT_PROFILE_DEPTH=40 Max stack depth
|
|
73
|
+
SCINT_IO_TRACE=/tmp/scint-io.jsonl Write JSONL IO trace
|
|
74
|
+
HELP
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "cgi"
|
|
6
|
+
|
|
7
|
+
module Scint
|
|
8
|
+
# Session-scoped credential store for HTTP gem sources.
|
|
9
|
+
#
|
|
10
|
+
# Assembled early from config files, then enriched as Gemfiles and lockfiles
|
|
11
|
+
# are parsed. By download time, all credentials are available.
|
|
12
|
+
#
|
|
13
|
+
# Lookup order (first match wins):
|
|
14
|
+
# 1. Inline credentials in the request URI itself (user:pass@host)
|
|
15
|
+
# 2. Credentials registered at runtime (from Gemfile source: URIs)
|
|
16
|
+
# 3. Scint config: $XDG_CONFIG_HOME/scint/credentials
|
|
17
|
+
# 4. Bundler config: ~/.bundle/config
|
|
18
|
+
# 5. Environment variables (BUNDLE_HOST__NAME format)
|
|
19
|
+
#
|
|
20
|
+
# All config files use Bundler's key format:
|
|
21
|
+
# BUNDLE_PKGS__SHOPIFY__IO: "token:secret"
|
|
22
|
+
#
|
|
23
|
+
# Key derivation: dots → __, dashes → ___, uppercased, BUNDLE_ prefix.
|
|
24
|
+
# Value is "user:password" for HTTP Basic Auth.
|
|
25
|
+
class Credentials
|
|
26
|
+
def initialize
|
|
27
|
+
@registered = {} # "host" => "user:password"
|
|
28
|
+
@mutex = Thread::Mutex.new
|
|
29
|
+
@file_config = load_config_files
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register credentials extracted from a URI with inline user:pass@host.
|
|
33
|
+
# Call from Gemfile/lockfile parsing so creds survive lockfile round-trips.
|
|
34
|
+
def register_uri(uri)
|
|
35
|
+
uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
|
|
36
|
+
return unless uri.user
|
|
37
|
+
|
|
38
|
+
user = CGI.unescape(uri.user)
|
|
39
|
+
password = uri.password ? CGI.unescape(uri.password) : nil
|
|
40
|
+
auth = password ? "#{user}:#{password}" : user
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize { @registered[uri.host] = auth }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Scan an array of source hashes (from Gemfile parser) for inline creds.
|
|
46
|
+
def register_sources(sources)
|
|
47
|
+
sources.each do |src|
|
|
48
|
+
register_uri(src[:uri]) if src[:uri]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Scan dependencies for source: options with inline creds.
|
|
53
|
+
def register_dependencies(dependencies)
|
|
54
|
+
dependencies.each do |dep|
|
|
55
|
+
src = dep.respond_to?(:source_options) ? dep.source_options[:source] : nil
|
|
56
|
+
register_uri(src) if src
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Scan lockfile source objects for remotes with inline creds.
|
|
61
|
+
def register_lockfile_sources(sources)
|
|
62
|
+
sources.each do |src|
|
|
63
|
+
if src.respond_to?(:remotes)
|
|
64
|
+
src.remotes.each { |r| register_uri(r) }
|
|
65
|
+
elsif src.respond_to?(:uri)
|
|
66
|
+
register_uri(src.uri)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns [user, password] for the given URI, or nil.
|
|
72
|
+
def for_uri(uri)
|
|
73
|
+
uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
|
|
74
|
+
|
|
75
|
+
# 1. Inline credentials in the URI itself
|
|
76
|
+
if uri.user
|
|
77
|
+
user = CGI.unescape(uri.user)
|
|
78
|
+
password = uri.password ? CGI.unescape(uri.password) : nil
|
|
79
|
+
return [user, password]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 2–5. Registered + config files + env
|
|
83
|
+
auth = lookup_host(uri.host)
|
|
84
|
+
return nil unless auth
|
|
85
|
+
|
|
86
|
+
user, password = auth.split(":", 2)
|
|
87
|
+
[user, password]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Apply Basic Auth to a Net::HTTP::Request if credentials exist.
|
|
91
|
+
def apply!(request, uri)
|
|
92
|
+
uri = URI.parse(uri.to_s) unless uri.is_a?(URI)
|
|
93
|
+
creds = for_uri(uri)
|
|
94
|
+
return unless creds
|
|
95
|
+
|
|
96
|
+
user, password = creds
|
|
97
|
+
request.basic_auth(user, password || "")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def lookup_host(host)
|
|
103
|
+
return nil unless host
|
|
104
|
+
|
|
105
|
+
# 2. Runtime-registered (from Gemfile inline URIs)
|
|
106
|
+
registered = @mutex.synchronize { @registered[host] }
|
|
107
|
+
return registered if registered
|
|
108
|
+
|
|
109
|
+
# 3–4. Config files (scint, then bundler)
|
|
110
|
+
key = self.class.key_for_host(host)
|
|
111
|
+
val = @file_config[key]
|
|
112
|
+
return val if val
|
|
113
|
+
|
|
114
|
+
# 5. Environment variable
|
|
115
|
+
ENV[key]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def load_config_files
|
|
119
|
+
config = {}
|
|
120
|
+
# Load in reverse priority (later overrides earlier)
|
|
121
|
+
load_yaml_into(config, bundler_config_path)
|
|
122
|
+
load_yaml_into(config, scint_credentials_path)
|
|
123
|
+
config
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def load_yaml_into(config, path)
|
|
127
|
+
return unless path && File.exist?(path)
|
|
128
|
+
|
|
129
|
+
data = YAML.safe_load(File.read(path))
|
|
130
|
+
config.merge!(data) if data.is_a?(Hash)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
# Ignore malformed files
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scint_credentials_path
|
|
136
|
+
xdg = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
137
|
+
File.join(xdg, "scint", "credentials")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def bundler_config_path
|
|
141
|
+
File.join(Dir.home, ".bundle", "config")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Convert "pkgs.shopify.io" → "BUNDLE_PKGS__SHOPIFY__IO"
|
|
145
|
+
def self.key_for_host(host)
|
|
146
|
+
key = host.to_s.dup
|
|
147
|
+
key.gsub!(".", "__")
|
|
148
|
+
key.gsub!("-", "___")
|
|
149
|
+
key.upcase!
|
|
150
|
+
"BUNDLE_#{key}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "../fs"
|
|
7
|
+
|
|
8
|
+
module Scint
|
|
9
|
+
module Debug
|
|
10
|
+
module IOTrace
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
@enabled = false
|
|
14
|
+
@log_io = nil
|
|
15
|
+
@log_path = nil
|
|
16
|
+
@mutex = Thread::Mutex.new
|
|
17
|
+
@patched = false
|
|
18
|
+
@patches = []
|
|
19
|
+
|
|
20
|
+
FILE_METHODS = %i[
|
|
21
|
+
open read binread write binwrite rename link symlink unlink delete
|
|
22
|
+
stat lstat exist? file? directory?
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
DIR_METHODS = %i[
|
|
26
|
+
glob children entries mkdir chdir exist?
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
PROCESS_METHODS = %i[spawn].freeze
|
|
30
|
+
|
|
31
|
+
KERNEL_METHODS = %i[system].freeze
|
|
32
|
+
|
|
33
|
+
def enable!(path)
|
|
34
|
+
expanded = File.expand_path(path)
|
|
35
|
+
FS.mkdir_p(File.dirname(expanded))
|
|
36
|
+
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
disable_unlocked if @enabled
|
|
39
|
+
patch_methods!
|
|
40
|
+
# Start a fresh trace per run to avoid mixing data across installs.
|
|
41
|
+
@log_io = File.open(expanded, "w")
|
|
42
|
+
@log_path = expanded
|
|
43
|
+
@enabled = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
log("trace.start", path: expanded)
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def disable!
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
disable_unlocked
|
|
53
|
+
unpatch_methods!
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def enabled?
|
|
58
|
+
@enabled
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def log(op, **data)
|
|
62
|
+
return unless @enabled
|
|
63
|
+
return if Thread.current[:scint_iotrace_guard]
|
|
64
|
+
|
|
65
|
+
Thread.current[:scint_iotrace_guard] = true
|
|
66
|
+
begin
|
|
67
|
+
payload = {
|
|
68
|
+
ts: Time.now.utc.iso8601(6),
|
|
69
|
+
op: op,
|
|
70
|
+
pid: Process.pid,
|
|
71
|
+
tid: Thread.current.object_id,
|
|
72
|
+
data: sanitize(data),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
loc = caller_locations(1, 8)&.find { |frame| frame.path && !frame.path.include?("io_trace.rb") }
|
|
76
|
+
payload[:loc] = "#{loc.path}:#{loc.lineno}" if loc
|
|
77
|
+
|
|
78
|
+
line = JSON.generate(payload)
|
|
79
|
+
@mutex.synchronize do
|
|
80
|
+
return unless @enabled && @log_io
|
|
81
|
+
@log_io.write(line)
|
|
82
|
+
@log_io.write("\n")
|
|
83
|
+
@log_io.flush
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
# Logging must never break install flow.
|
|
87
|
+
ensure
|
|
88
|
+
Thread.current[:scint_iotrace_guard] = false
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def patch_methods!
|
|
93
|
+
return if @patched
|
|
94
|
+
|
|
95
|
+
patch_singleton_methods(File, FILE_METHODS, "File")
|
|
96
|
+
patch_singleton_methods(Dir, DIR_METHODS, "Dir")
|
|
97
|
+
patch_singleton_methods(Process, PROCESS_METHODS, "Process")
|
|
98
|
+
patch_instance_methods(Kernel, KERNEL_METHODS, "Kernel")
|
|
99
|
+
|
|
100
|
+
@patched = true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unpatch_methods!
|
|
104
|
+
return unless @patched
|
|
105
|
+
|
|
106
|
+
@patches.reverse_each do |entry|
|
|
107
|
+
container = entry[:container]
|
|
108
|
+
method_name = entry[:method]
|
|
109
|
+
original_name = entry[:original]
|
|
110
|
+
|
|
111
|
+
next unless container.method_defined?(original_name) || container.private_method_defined?(original_name)
|
|
112
|
+
|
|
113
|
+
container.send(:alias_method, method_name, original_name)
|
|
114
|
+
container.send(:remove_method, original_name)
|
|
115
|
+
|
|
116
|
+
visibility = entry[:visibility]
|
|
117
|
+
container.send(visibility, method_name) if visibility
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@patches.clear
|
|
121
|
+
@patched = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def patch_singleton_methods(target, methods, prefix)
|
|
125
|
+
singleton = target.singleton_class
|
|
126
|
+
|
|
127
|
+
methods.each do |method_name|
|
|
128
|
+
next unless singleton.method_defined?(method_name) || singleton.private_method_defined?(method_name)
|
|
129
|
+
|
|
130
|
+
patch_method(singleton, method_name, "#{prefix}.#{method_name}")
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def patch_instance_methods(mod, methods, prefix)
|
|
135
|
+
methods.each do |method_name|
|
|
136
|
+
next unless mod.method_defined?(method_name) || mod.private_method_defined?(method_name)
|
|
137
|
+
|
|
138
|
+
patch_method(mod, method_name, "#{prefix}.#{method_name}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def patch_method(container, method_name, op_name)
|
|
143
|
+
original_name = "__scint_iotrace_orig_#{method_name}".to_sym
|
|
144
|
+
return if container.method_defined?(original_name) || container.private_method_defined?(original_name)
|
|
145
|
+
|
|
146
|
+
visibility = method_visibility(container, method_name)
|
|
147
|
+
|
|
148
|
+
container.send(:alias_method, original_name, method_name)
|
|
149
|
+
container.send(:define_method, method_name) do |*args, **kwargs, &block|
|
|
150
|
+
Scint::Debug::IOTrace.log(op_name, args: args, kwargs: kwargs)
|
|
151
|
+
if kwargs.empty?
|
|
152
|
+
send(original_name, *args, &block)
|
|
153
|
+
else
|
|
154
|
+
send(original_name, *args, **kwargs, &block)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
container.send(visibility, method_name) if visibility
|
|
159
|
+
|
|
160
|
+
@patches << {
|
|
161
|
+
container: container,
|
|
162
|
+
method: method_name,
|
|
163
|
+
original: original_name,
|
|
164
|
+
visibility: visibility,
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def method_visibility(container, method_name)
|
|
169
|
+
return :private if container.private_method_defined?(method_name)
|
|
170
|
+
return :protected if container.protected_method_defined?(method_name)
|
|
171
|
+
return :public if container.method_defined?(method_name)
|
|
172
|
+
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def disable_unlocked
|
|
177
|
+
return unless @enabled || @log_io
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
if @enabled && @log_io
|
|
181
|
+
@log_io.write(JSON.generate({ ts: Time.now.utc.iso8601(6), op: "trace.stop", pid: Process.pid, tid: Thread.current.object_id }) + "\n")
|
|
182
|
+
@log_io.flush
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@enabled = false
|
|
189
|
+
io = @log_io
|
|
190
|
+
@log_io = nil
|
|
191
|
+
@log_path = nil
|
|
192
|
+
io&.close
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def sanitize(value, depth = 0)
|
|
196
|
+
return "..." if depth > 3
|
|
197
|
+
|
|
198
|
+
case value
|
|
199
|
+
when String
|
|
200
|
+
value.length > 300 ? "#{value.byteslice(0, 300)}..." : value
|
|
201
|
+
when Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
202
|
+
value
|
|
203
|
+
when Array
|
|
204
|
+
value.first(10).map { |v| sanitize(v, depth + 1) }
|
|
205
|
+
when Hash
|
|
206
|
+
out = {}
|
|
207
|
+
value.each do |k, v|
|
|
208
|
+
out[sanitize(k, depth + 1)] = sanitize(v, depth + 1)
|
|
209
|
+
break if out.length >= 20
|
|
210
|
+
end
|
|
211
|
+
out
|
|
212
|
+
else
|
|
213
|
+
value.to_s
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "../fs"
|
|
6
|
+
|
|
7
|
+
module Scint
|
|
8
|
+
module Debug
|
|
9
|
+
class Sampler
|
|
10
|
+
DEFAULT_HZ = 250
|
|
11
|
+
DEFAULT_MAX_DEPTH = 40
|
|
12
|
+
|
|
13
|
+
def initialize(path:, hz: DEFAULT_HZ, max_depth: DEFAULT_MAX_DEPTH)
|
|
14
|
+
@path = File.expand_path(path)
|
|
15
|
+
@hz = [hz.to_i, 1].max
|
|
16
|
+
@interval = 1.0 / @hz
|
|
17
|
+
@max_depth = [max_depth.to_i, 1].max
|
|
18
|
+
|
|
19
|
+
@mutex = Thread::Mutex.new
|
|
20
|
+
@thread = nil
|
|
21
|
+
@stop = false
|
|
22
|
+
|
|
23
|
+
@started_at_wall = nil
|
|
24
|
+
@started_at_mono = nil
|
|
25
|
+
@finished_at_wall = nil
|
|
26
|
+
|
|
27
|
+
@samples = 0
|
|
28
|
+
@stack_counts = Hash.new(0)
|
|
29
|
+
@frame_counts = Hash.new(0)
|
|
30
|
+
@sample_errors = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
return if @thread&.alive?
|
|
35
|
+
|
|
36
|
+
@started_at_wall = Time.now.utc
|
|
37
|
+
@started_at_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
@stop = false
|
|
39
|
+
|
|
40
|
+
@thread = Thread.new do
|
|
41
|
+
loop do
|
|
42
|
+
break if stop?
|
|
43
|
+
|
|
44
|
+
sample_once
|
|
45
|
+
sleep(@interval)
|
|
46
|
+
end
|
|
47
|
+
rescue StandardError
|
|
48
|
+
@mutex.synchronize { @sample_errors += 1 }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop(exit_code: nil)
|
|
53
|
+
thread = nil
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@stop = true
|
|
56
|
+
thread = @thread
|
|
57
|
+
end
|
|
58
|
+
thread&.join
|
|
59
|
+
|
|
60
|
+
@finished_at_wall = Time.now.utc
|
|
61
|
+
write_report(exit_code: exit_code)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def stop?
|
|
67
|
+
@mutex.synchronize { @stop }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sample_once
|
|
71
|
+
thread_snapshots = []
|
|
72
|
+
|
|
73
|
+
Thread.list.each do |thr|
|
|
74
|
+
next if thr == Thread.current
|
|
75
|
+
next unless thr.alive?
|
|
76
|
+
|
|
77
|
+
stack = thr.backtrace_locations
|
|
78
|
+
next if stack.nil? || stack.empty?
|
|
79
|
+
|
|
80
|
+
frames = stack.first(@max_depth).map { |loc| frame_string(loc) }
|
|
81
|
+
next if frames.empty?
|
|
82
|
+
|
|
83
|
+
thread_snapshots << frames
|
|
84
|
+
rescue StandardError
|
|
85
|
+
@mutex.synchronize { @sample_errors += 1 }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
thread_snapshots.each do |frames|
|
|
90
|
+
@samples += 1
|
|
91
|
+
@stack_counts[frames.join(";")] += 1
|
|
92
|
+
@frame_counts[frames.first] += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def frame_string(loc)
|
|
98
|
+
path = loc.path || "(unknown)"
|
|
99
|
+
"#{path}:#{loc.lineno}:in `#{loc.base_label}`"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_report(exit_code:)
|
|
103
|
+
finished_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
|
+
started_mono = @started_at_mono || finished_mono
|
|
105
|
+
elapsed_ms = ((finished_mono - started_mono) * 1000.0).round
|
|
106
|
+
|
|
107
|
+
report = {
|
|
108
|
+
version: 1,
|
|
109
|
+
mode: "sampling",
|
|
110
|
+
sample_hz: @hz,
|
|
111
|
+
max_depth: @max_depth,
|
|
112
|
+
started_at: (@started_at_wall || Time.now.utc).iso8601(6),
|
|
113
|
+
finished_at: (@finished_at_wall || Time.now.utc).iso8601(6),
|
|
114
|
+
wall_ms: elapsed_ms,
|
|
115
|
+
exit_code: exit_code,
|
|
116
|
+
samples: @samples,
|
|
117
|
+
unique_stacks: @stack_counts.size,
|
|
118
|
+
sample_errors: @sample_errors,
|
|
119
|
+
top_frames: top_entries(@frame_counts, 50),
|
|
120
|
+
top_stacks: top_entries(@stack_counts, 200),
|
|
121
|
+
gc: {
|
|
122
|
+
count: GC.count,
|
|
123
|
+
total_allocated_objects: GC.stat(:total_allocated_objects),
|
|
124
|
+
heap_live_slots: GC.stat(:heap_live_slots),
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
FS.atomic_write(@path, JSON.pretty_generate(report))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def top_entries(hash, limit)
|
|
132
|
+
hash.sort_by { |_k, v| -v }.first(limit).map do |key, count|
|
|
133
|
+
{ key: key, samples: count }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|