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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/FEATURES.md +13 -0
  3. data/README.md +216 -0
  4. data/bin/bundler-vs-scint +233 -0
  5. data/bin/scint +35 -0
  6. data/bin/scint-io-summary +46 -0
  7. data/bin/scint-syscall-trace +41 -0
  8. data/lib/bundler/setup.rb +5 -0
  9. data/lib/bundler.rb +168 -0
  10. data/lib/scint/cache/layout.rb +131 -0
  11. data/lib/scint/cache/metadata_store.rb +75 -0
  12. data/lib/scint/cache/prewarm.rb +192 -0
  13. data/lib/scint/cli/add.rb +85 -0
  14. data/lib/scint/cli/cache.rb +316 -0
  15. data/lib/scint/cli/exec.rb +150 -0
  16. data/lib/scint/cli/install.rb +1047 -0
  17. data/lib/scint/cli/remove.rb +60 -0
  18. data/lib/scint/cli.rb +77 -0
  19. data/lib/scint/commands/exec.rb +17 -0
  20. data/lib/scint/commands/install.rb +17 -0
  21. data/lib/scint/credentials.rb +153 -0
  22. data/lib/scint/debug/io_trace.rb +218 -0
  23. data/lib/scint/debug/sampler.rb +138 -0
  24. data/lib/scint/downloader/fetcher.rb +113 -0
  25. data/lib/scint/downloader/pool.rb +112 -0
  26. data/lib/scint/errors.rb +63 -0
  27. data/lib/scint/fs.rb +119 -0
  28. data/lib/scint/gem/extractor.rb +86 -0
  29. data/lib/scint/gem/package.rb +62 -0
  30. data/lib/scint/gemfile/dependency.rb +30 -0
  31. data/lib/scint/gemfile/editor.rb +93 -0
  32. data/lib/scint/gemfile/parser.rb +275 -0
  33. data/lib/scint/index/cache.rb +166 -0
  34. data/lib/scint/index/client.rb +301 -0
  35. data/lib/scint/index/parser.rb +142 -0
  36. data/lib/scint/installer/extension_builder.rb +264 -0
  37. data/lib/scint/installer/linker.rb +226 -0
  38. data/lib/scint/installer/planner.rb +140 -0
  39. data/lib/scint/installer/preparer.rb +207 -0
  40. data/lib/scint/lockfile/parser.rb +251 -0
  41. data/lib/scint/lockfile/writer.rb +178 -0
  42. data/lib/scint/platform.rb +71 -0
  43. data/lib/scint/progress.rb +579 -0
  44. data/lib/scint/resolver/provider.rb +230 -0
  45. data/lib/scint/resolver/resolver.rb +249 -0
  46. data/lib/scint/runtime/exec.rb +141 -0
  47. data/lib/scint/runtime/setup.rb +45 -0
  48. data/lib/scint/scheduler.rb +392 -0
  49. data/lib/scint/source/base.rb +46 -0
  50. data/lib/scint/source/git.rb +92 -0
  51. data/lib/scint/source/path.rb +70 -0
  52. data/lib/scint/source/rubygems.rb +79 -0
  53. data/lib/scint/vendor/pub_grub/assignment.rb +20 -0
  54. data/lib/scint/vendor/pub_grub/basic_package_source.rb +169 -0
  55. data/lib/scint/vendor/pub_grub/failure_writer.rb +182 -0
  56. data/lib/scint/vendor/pub_grub/incompatibility.rb +150 -0
  57. data/lib/scint/vendor/pub_grub/package.rb +43 -0
  58. data/lib/scint/vendor/pub_grub/partial_solution.rb +121 -0
  59. data/lib/scint/vendor/pub_grub/rubygems.rb +45 -0
  60. data/lib/scint/vendor/pub_grub/solve_failure.rb +19 -0
  61. data/lib/scint/vendor/pub_grub/static_package_source.rb +61 -0
  62. data/lib/scint/vendor/pub_grub/strategy.rb +42 -0
  63. data/lib/scint/vendor/pub_grub/term.rb +105 -0
  64. data/lib/scint/vendor/pub_grub/version.rb +3 -0
  65. data/lib/scint/vendor/pub_grub/version_constraint.rb +129 -0
  66. data/lib/scint/vendor/pub_grub/version_range.rb +423 -0
  67. data/lib/scint/vendor/pub_grub/version_solver.rb +236 -0
  68. data/lib/scint/vendor/pub_grub/version_union.rb +178 -0
  69. data/lib/scint/vendor/pub_grub.rb +32 -0
  70. data/lib/scint/worker_pool.rb +114 -0
  71. data/lib/scint.rb +87 -0
  72. 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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli/exec"
4
+
5
+ module Scint
6
+ module Commands
7
+ class Exec
8
+ def initialize(argv)
9
+ @impl = CLI::Exec.new(argv)
10
+ end
11
+
12
+ def run
13
+ @impl.run
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cli/install"
4
+
5
+ module Scint
6
+ module Commands
7
+ class Install
8
+ def initialize(argv)
9
+ @impl = CLI::Install.new(argv)
10
+ end
11
+
12
+ def run
13
+ @impl.run
14
+ end
15
+ end
16
+ end
17
+ 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