bulldogger 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/LICENSE +21 -0
- data/README.md +262 -0
- data/docs/design-decisions.md +142 -0
- data/docs/evidence-schema.md +388 -0
- data/docs/maintenance.md +92 -0
- data/docs/trace-schema.md +118 -0
- data/lib/bulldogger/capture.rb +98 -0
- data/lib/bulldogger/config.rb +57 -0
- data/lib/bulldogger/evidence.rb +106 -0
- data/lib/bulldogger/formatter.rb +103 -0
- data/lib/bulldogger/frame_source.rb +147 -0
- data/lib/bulldogger/integrations/minitest.rb +87 -0
- data/lib/bulldogger/integrations/rspec.rb +56 -0
- data/lib/bulldogger/minitest.rb +7 -0
- data/lib/bulldogger/pending.rb +58 -0
- data/lib/bulldogger/probe/bucket.rb +90 -0
- data/lib/bulldogger/probe/comparator.rb +95 -0
- data/lib/bulldogger/probe/method_stats.rb +215 -0
- data/lib/bulldogger/probe/raise_tracker.rb +141 -0
- data/lib/bulldogger/probe/registry.rb +32 -0
- data/lib/bulldogger/probe/session.rb +159 -0
- data/lib/bulldogger/probe/target.rb +13 -0
- data/lib/bulldogger/probe/target_resolver.rb +86 -0
- data/lib/bulldogger/probe/writer.rb +61 -0
- data/lib/bulldogger/probe.rb +36 -0
- data/lib/bulldogger/record/session.rb +334 -0
- data/lib/bulldogger/record/sqlite_converter.rb +86 -0
- data/lib/bulldogger/record/writer.rb +67 -0
- data/lib/bulldogger/record.rb +51 -0
- data/lib/bulldogger/redactor.rb +30 -0
- data/lib/bulldogger/rspec.rb +7 -0
- data/lib/bulldogger/run.rb +113 -0
- data/lib/bulldogger/version.rb +5 -0
- data/lib/bulldogger.rb +133 -0
- data/skills/bulldogger/SKILL.md +37 -0
- data/skills/bulldogger/references/failure-evidence.md +56 -0
- data/skills/bulldogger/references/probe.md +36 -0
- data/skills/bulldogger/references/record.md +28 -0
- metadata +153 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
module Probe
|
|
5
|
+
# One params[name]/returns aggregate: a running tally of observed
|
|
6
|
+
# classes and nil-ness (updated on every call), plus a bounded,
|
|
7
|
+
# fully-serialized sample of the first max_samples values.
|
|
8
|
+
#
|
|
9
|
+
# Tally and sample are deliberately split: contract-verbs.md
|
|
10
|
+
# measured full serialization at ~24us/event, which would blow the
|
|
11
|
+
# probe overhead budget on a call-dense target if it ran on every
|
|
12
|
+
# call. Class name and nil? never touch #inspect, so the tally
|
|
13
|
+
# stays cheap regardless of how many calls a session sees.
|
|
14
|
+
class Bucket
|
|
15
|
+
def initialize(formatter:, max_samples:, redacted_name: false)
|
|
16
|
+
@formatter = formatter
|
|
17
|
+
@max_samples = max_samples
|
|
18
|
+
@redacted_name = redacted_name
|
|
19
|
+
@classes = Hash.new(0)
|
|
20
|
+
@nil_count = 0
|
|
21
|
+
@samples = []
|
|
22
|
+
@count = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record(value)
|
|
26
|
+
@count += 1
|
|
27
|
+
@classes[safe_class_name(value)] += 1
|
|
28
|
+
@nil_count += 1 if value.nil?
|
|
29
|
+
@samples << sample_for(value) if @samples.size < @max_samples
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
h = { "classes" => @classes, "nil_count" => @nil_count, "samples" => @samples }
|
|
34
|
+
omitted = @count - @samples.size
|
|
35
|
+
# Present only when something was actually cut, so a reader
|
|
36
|
+
# can trust its absence -- the same rule contract.md applies
|
|
37
|
+
# to frames_omitted/locals_omitted.
|
|
38
|
+
h["samples_omitted"] = omitted if omitted.positive?
|
|
39
|
+
h
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Folds another Bucket's tally into this one. This is the
|
|
43
|
+
# finish-time merge step for thread-local aggregation: each
|
|
44
|
+
# thread records into its own Bucket with no lock, and totals
|
|
45
|
+
# are combined once, in one place, instead of every call taking
|
|
46
|
+
# a Mutex to update a single shared Bucket.
|
|
47
|
+
#
|
|
48
|
+
# Samples are re-capped at @max_samples here too: each
|
|
49
|
+
# thread-local Bucket already capped its own samples
|
|
50
|
+
# independently, so without this cap a target hit by N threads
|
|
51
|
+
# could publish up to N * max_samples samples, silently
|
|
52
|
+
# widening the documented limit just because more than one
|
|
53
|
+
# thread happened to record some of the calls.
|
|
54
|
+
def merge!(other)
|
|
55
|
+
@count += other.count
|
|
56
|
+
other.classes.each { |klass, n| @classes[klass] += n }
|
|
57
|
+
@nil_count += other.nil_count
|
|
58
|
+
other.samples.each do |sample|
|
|
59
|
+
break if @samples.size >= @max_samples
|
|
60
|
+
|
|
61
|
+
@samples << sample
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
protected
|
|
66
|
+
|
|
67
|
+
attr_reader :count, :classes, :nil_count, :samples
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Redaction gates on the parameter *name*, decided once at
|
|
72
|
+
# Bucket construction, never on the value: this bucket's caller
|
|
73
|
+
# (MethodStats) already checked the name against redact_patterns
|
|
74
|
+
# before a single value was ever inspected, matching Redactor's
|
|
75
|
+
# own rule of checking the name before touching the value.
|
|
76
|
+
def sample_for(value)
|
|
77
|
+
return { "redacted" => true, "reason" => "name" } if @redacted_name
|
|
78
|
+
|
|
79
|
+
@formatter.format(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def safe_class_name(value)
|
|
83
|
+
klass = value.class
|
|
84
|
+
klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
|
|
85
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
86
|
+
"Object"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bulldogger
|
|
6
|
+
module Probe
|
|
7
|
+
# Compares two probe evidence files for behavior-preservation:
|
|
8
|
+
# "did this refactor change what the probed methods actually do."
|
|
9
|
+
#
|
|
10
|
+
# Compares *shape* (classes seen, nil_count, raised_exits, calls,
|
|
11
|
+
# the set of callers, parameters), not raw sample values, because
|
|
12
|
+
# the default `inspect` embeds an object's memory address (e.g.
|
|
13
|
+
# `#<Order:0x000000012a>`), so two probes of *identical*,
|
|
14
|
+
# unchanged code would otherwise report differences on every run.
|
|
15
|
+
# Samples are still diffed as a secondary signal, but only after
|
|
16
|
+
# normalizing `0x[0-9a-f]+` out of them for exactly this reason.
|
|
17
|
+
module Comparator
|
|
18
|
+
HEX_ADDRESS = /0x[0-9a-f]+/i
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def compare(path_a, path_b)
|
|
23
|
+
a = JSON.parse(File.read(path_a))
|
|
24
|
+
b = JSON.parse(File.read(path_b))
|
|
25
|
+
differences = []
|
|
26
|
+
compare_methods(a["methods"] || {}, b["methods"] || {}, differences)
|
|
27
|
+
{ "identical" => differences.empty?, "differences" => differences }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def compare_methods(methods_a, methods_b, differences)
|
|
31
|
+
(methods_a.keys | methods_b.keys).each do |label|
|
|
32
|
+
ma = methods_a[label]
|
|
33
|
+
mb = methods_b[label]
|
|
34
|
+
if ma.nil? || mb.nil?
|
|
35
|
+
differences << "#{label}: only present in #{ma.nil? ? 'b' : 'a'}"
|
|
36
|
+
next
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
compare_method(label, ma, mb, differences)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compare_method(label, ma, mb, differences)
|
|
44
|
+
add_diff(differences, label, "calls", ma["calls"], mb["calls"])
|
|
45
|
+
add_diff(differences, label, "raised_exits", ma["raised_exits"], mb["raised_exits"])
|
|
46
|
+
add_diff(differences, label, "parameters", ma["parameters"], mb["parameters"])
|
|
47
|
+
add_diff(differences, label, "raised", ma["raised"], mb["raised"])
|
|
48
|
+
# Set only, not counts: contract-verbs.md compares "the set of
|
|
49
|
+
# callers", not how many times each one fired.
|
|
50
|
+
add_diff(differences, label, "callers", (ma["callers"] || {}).keys.sort, (mb["callers"] || {}).keys.sort)
|
|
51
|
+
compare_bucket("#{label}.returns", ma["returns"], mb["returns"], differences)
|
|
52
|
+
compare_params(label, ma["params"] || {}, mb["params"] || {}, differences)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compare_params(label, params_a, params_b, differences)
|
|
56
|
+
(params_a.keys | params_b.keys).each do |name|
|
|
57
|
+
compare_bucket("#{label} param #{name}", params_a[name], params_b[name], differences)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compare_bucket(prefix, bucket_a, bucket_b, differences)
|
|
62
|
+
bucket_a ||= {}
|
|
63
|
+
bucket_b ||= {}
|
|
64
|
+
add_diff(differences, prefix, "classes", bucket_a["classes"], bucket_b["classes"])
|
|
65
|
+
add_diff(differences, prefix, "nil_count", bucket_a["nil_count"], bucket_b["nil_count"])
|
|
66
|
+
compare_samples(prefix, bucket_a["samples"] || [], bucket_b["samples"] || [], differences)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compare_samples(prefix, samples_a, samples_b, differences)
|
|
70
|
+
normalized_a = normalize_samples(samples_a)
|
|
71
|
+
normalized_b = normalize_samples(samples_b)
|
|
72
|
+
return if normalized_a == normalized_b
|
|
73
|
+
|
|
74
|
+
differences << "#{prefix}.samples changed (after normalizing object addresses): " \
|
|
75
|
+
"a=#{normalized_a.inspect} b=#{normalized_b.inspect}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_samples(samples)
|
|
79
|
+
samples.map do |sample|
|
|
80
|
+
next sample unless sample.is_a?(Hash) && sample["value"].is_a?(String)
|
|
81
|
+
|
|
82
|
+
sample.merge("value" => sample["value"].gsub(HEX_ADDRESS, "0x…"))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_diff(differences, label, field, value_a, value_b)
|
|
87
|
+
return if value_a == value_b
|
|
88
|
+
|
|
89
|
+
differences << "#{label}.#{field} changed: a=#{value_a.inspect} b=#{value_b.inspect}"
|
|
90
|
+
end
|
|
91
|
+
private_class_method :compare_methods, :compare_method, :compare_params, :compare_bucket,
|
|
92
|
+
:compare_samples, :normalize_samples, :add_diff
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bucket"
|
|
4
|
+
|
|
5
|
+
module Bulldogger
|
|
6
|
+
module Probe
|
|
7
|
+
# Aggregates every :call/:return pair observed for one probe
|
|
8
|
+
# target into the "methods"[label] shape the evidence JSON
|
|
9
|
+
# publishes: call count, per-parameter and return-value shape, the
|
|
10
|
+
# raise-exit count, and the set of call sites.
|
|
11
|
+
#
|
|
12
|
+
# record_call/record_return take no lock: each caller writes only
|
|
13
|
+
# into its own ThreadLocal (below), reached through Thread.current[]
|
|
14
|
+
# -- fiber-local, like RaiseTracker's own storage, so two fibers on
|
|
15
|
+
# the same OS thread still get separate slots -- keyed uniquely to
|
|
16
|
+
# this MethodStats instance, so two threads calling the same probed
|
|
17
|
+
# method concurrently never touch the same mutable state. A per-call
|
|
18
|
+
# Mutex#synchronize was measured to be on the hot path for every
|
|
19
|
+
# single call and return; a fiber only pays a lock once -- the first
|
|
20
|
+
# time it is ever seen by this target, to register its ThreadLocal
|
|
21
|
+
# in @locals -- and #to_h folds every one of them into the published
|
|
22
|
+
# totals exactly once, at finish time.
|
|
23
|
+
class MethodStats
|
|
24
|
+
# One fiber's own, lock-free view of this target. Merged into
|
|
25
|
+
# the instance's published totals by #merge_local, once, in
|
|
26
|
+
# #to_h.
|
|
27
|
+
ThreadLocal = Struct.new(:calls, :raised_exits, :param_buckets, :returns, :raised, :callers)
|
|
28
|
+
|
|
29
|
+
def initialize(target:, formatter:, redactor:, max_samples:)
|
|
30
|
+
@formatter = formatter
|
|
31
|
+
@redactor = redactor
|
|
32
|
+
@max_samples = max_samples
|
|
33
|
+
@parameters = declared_parameters(target.unbound_method)
|
|
34
|
+
|
|
35
|
+
@calls = 0
|
|
36
|
+
@raised_exits = 0
|
|
37
|
+
@param_buckets = build_param_buckets
|
|
38
|
+
@returns = new_returns_bucket
|
|
39
|
+
@raised = Hash.new(0)
|
|
40
|
+
# key => [count, one representative Thread::Backtrace::Location].
|
|
41
|
+
# One Hash, not two: a second hash keyed the same way would be
|
|
42
|
+
# a second lookup on every single call for no benefit -- see
|
|
43
|
+
# #record_caller.
|
|
44
|
+
@callers = {}
|
|
45
|
+
|
|
46
|
+
# Unique per instance (not a fixed name): two targets probed
|
|
47
|
+
# in the same session must not share one thread-local slot.
|
|
48
|
+
@thread_key = :"bulldogger_probe_method_stats_#{object_id}"
|
|
49
|
+
@locals = []
|
|
50
|
+
@locals_mutex = Mutex.new
|
|
51
|
+
@merge_mutex = Mutex.new
|
|
52
|
+
@merged = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def record_call(tp, caller_location)
|
|
56
|
+
local = thread_local
|
|
57
|
+
local.calls += 1
|
|
58
|
+
record_param_samples(local, tp.binding)
|
|
59
|
+
record_caller(local, caller_location)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# raised: true means contract-verbs.md's raise-exit discriminator
|
|
63
|
+
# fired for this return -- this call must not be counted as a
|
|
64
|
+
# nil return (that would fabricate a "returned nil" the method
|
|
65
|
+
# never actually did), so it updates the thread-local @raised
|
|
66
|
+
# instead of @returns and never touches the returns bucket at
|
|
67
|
+
# all.
|
|
68
|
+
def record_return(tp, raised:)
|
|
69
|
+
local = thread_local
|
|
70
|
+
if raised
|
|
71
|
+
local.raised_exits += 1
|
|
72
|
+
klass = RaiseTracker.instance.current_exception_class_name || "Object"
|
|
73
|
+
local.raised[klass] += 1
|
|
74
|
+
else
|
|
75
|
+
local.returns.record(tp.return_value)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
merge!
|
|
81
|
+
{
|
|
82
|
+
"calls" => @calls,
|
|
83
|
+
"raised_exits" => @raised_exits,
|
|
84
|
+
"parameters" => @parameters,
|
|
85
|
+
"params" => params_to_h,
|
|
86
|
+
"returns" => @returns.to_h,
|
|
87
|
+
"raised" => @raised,
|
|
88
|
+
"callers" => callers_to_h
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# UnboundMethod#parameters is declared, static shape -- computed
|
|
95
|
+
# once here, not derived from tp.parameters per call, since it
|
|
96
|
+
# cannot change between calls and re-deriving it every time would
|
|
97
|
+
# only add cost with no new information.
|
|
98
|
+
def declared_parameters(unbound_method)
|
|
99
|
+
unbound_method.parameters.map { |kind, name| [kind.to_s, name&.to_s] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_param_buckets
|
|
103
|
+
@parameters.each_with_object({}) do |(_kind, name), buckets|
|
|
104
|
+
next if name.nil? # anonymous *, **, or & has no bindable local to sample
|
|
105
|
+
|
|
106
|
+
buckets[name] = Bucket.new(formatter: @formatter, max_samples: @max_samples,
|
|
107
|
+
redacted_name: @redactor.redact_name?(name))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def new_returns_bucket
|
|
112
|
+
Bucket.new(formatter: @formatter, max_samples: @max_samples)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Lazily creates and registers this fiber's ThreadLocal on
|
|
116
|
+
# first touch. @locals_mutex is taken only here -- once per
|
|
117
|
+
# fiber per target, not once per call -- to append to the
|
|
118
|
+
# shared registry that #merge! later reads.
|
|
119
|
+
def thread_local
|
|
120
|
+
Thread.current[@thread_key] ||= begin
|
|
121
|
+
local = ThreadLocal.new(0, 0, build_param_buckets, new_returns_bucket, Hash.new(0), {})
|
|
122
|
+
@locals_mutex.synchronize { @locals << local }
|
|
123
|
+
local
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def record_param_samples(local, binding)
|
|
128
|
+
local.param_buckets.each do |name, bucket|
|
|
129
|
+
bucket.record(binding.local_variable_get(name.to_sym))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Tallies by a "path:lineno" String built from two cheap
|
|
134
|
+
# attribute reads, not by Location#to_s (which additionally
|
|
135
|
+
# resolves and formats a method label on every single call).
|
|
136
|
+
# Measured directly: this key plus one Hash lookup costs about
|
|
137
|
+
# half of the old to_s-keyed lookup. An Array key ([path,
|
|
138
|
+
# lineno]) was tried first and measured *slower* than the
|
|
139
|
+
# to_s baseline it was meant to beat -- Array#hash and #eql?
|
|
140
|
+
# walk and hash every element on every lookup, so a compound
|
|
141
|
+
# object is not automatically a cheap Hash key. A String is.
|
|
142
|
+
#
|
|
143
|
+
# One Hash lookup, not two: the value is a mutable [count,
|
|
144
|
+
# location] pair, so an existing entry is updated in place
|
|
145
|
+
# (cheap, no second Hash op) and only a first-time key pays for
|
|
146
|
+
# a Hash write. The full "path:line:in 'label'" String -- what
|
|
147
|
+
# the evidence actually publishes -- is built from the stored
|
|
148
|
+
# Location at most once per distinct call site, in
|
|
149
|
+
# #callers_to_h, not once per call.
|
|
150
|
+
def record_caller(local, caller_location)
|
|
151
|
+
return unless caller_location
|
|
152
|
+
|
|
153
|
+
key = "#{caller_location.path}:#{caller_location.lineno}"
|
|
154
|
+
entry = local.callers[key]
|
|
155
|
+
if entry
|
|
156
|
+
entry[0] += 1
|
|
157
|
+
else
|
|
158
|
+
local.callers[key] = [1, caller_location]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Runs once, the first time #to_h is called: folds every
|
|
163
|
+
# fiber's ThreadLocal into the published totals under
|
|
164
|
+
# @merge_mutex, so #to_h itself can be called more than once
|
|
165
|
+
# (Writer calls it exactly once per target today) without
|
|
166
|
+
# double-counting.
|
|
167
|
+
def merge!
|
|
168
|
+
return if @merged
|
|
169
|
+
|
|
170
|
+
@merge_mutex.synchronize do
|
|
171
|
+
return if @merged
|
|
172
|
+
|
|
173
|
+
# Snapshot under @locals_mutex, then merge outside it: a
|
|
174
|
+
# fiber could still be registering its own ThreadLocal
|
|
175
|
+
# (the append in #thread_local) concurrently with finish, so
|
|
176
|
+
# the read of @locals itself needs the same lock the writer
|
|
177
|
+
# uses, even though the per-call recording above never does.
|
|
178
|
+
locals = @locals_mutex.synchronize { @locals.dup }
|
|
179
|
+
locals.each { |local| merge_local(local) }
|
|
180
|
+
@merged = true
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def merge_local(local)
|
|
185
|
+
@calls += local.calls
|
|
186
|
+
@raised_exits += local.raised_exits
|
|
187
|
+
local.param_buckets.each { |name, bucket| @param_buckets[name].merge!(bucket) }
|
|
188
|
+
@returns.merge!(local.returns)
|
|
189
|
+
local.raised.each { |klass, count| @raised[klass] += count }
|
|
190
|
+
local.callers.each do |key, (count, location)|
|
|
191
|
+
entry = @callers[key]
|
|
192
|
+
if entry
|
|
193
|
+
entry[0] += count
|
|
194
|
+
else
|
|
195
|
+
@callers[key] = [count, location]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def params_to_h
|
|
201
|
+
@param_buckets.transform_values(&:to_h)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Location#to_s (the "path:line:in 'label'" format the evidence
|
|
205
|
+
# publishes) runs here, once per distinct call site total across
|
|
206
|
+
# every fiber -- not once per call, which is what made this the
|
|
207
|
+
# single most expensive part of the hook before this task.
|
|
208
|
+
def callers_to_h
|
|
209
|
+
@callers.each_with_object({}) do |(_key, (count, location)), result|
|
|
210
|
+
result[location.to_s] = count
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
module Probe
|
|
5
|
+
# Tells a probed method's normal return apart from a raise-exit.
|
|
6
|
+
# `:call`/`:return` can be targeted to one method, but `:raise`
|
|
7
|
+
# cannot (measured in contract-verbs.md): it fires globally, for
|
|
8
|
+
# every exception in the process. Distinguishing "returned nil"
|
|
9
|
+
# from "exited by raising" needs this second, global signal,
|
|
10
|
+
# correlated with each targeted call through the checkpoint below.
|
|
11
|
+
#
|
|
12
|
+
# Mechanism (contract-verbs.md, measured on ruby 4.0.6): a global
|
|
13
|
+
# counter that a `:raise` TracePoint increments and a `:rescue`
|
|
14
|
+
# TracePoint decrements. `:call` records the counter's value; at
|
|
15
|
+
# `:return`, a *positive delta* from that recorded value means an
|
|
16
|
+
# exception passed through this frame's unwind between the two
|
|
17
|
+
# events, because the caller's own `:rescue` fires only after
|
|
18
|
+
# `:return`, so the counter is still unbalanced at `:return` time.
|
|
19
|
+
#
|
|
20
|
+
# Delta, not the absolute counter: an absolute counter left
|
|
21
|
+
# unbalanced by a raise from *before* this call started (e.g. one
|
|
22
|
+
# whose `:rescue` fires after this session already released the
|
|
23
|
+
# tracker) would poison every later call on the same fiber. A
|
|
24
|
+
# delta only asks "did the counter move during *this* call", so a
|
|
25
|
+
# pre-existing imbalance cannot produce a false positive.
|
|
26
|
+
#
|
|
27
|
+
# Counter and checkpoint stack are fiber-local (Thread.current[]),
|
|
28
|
+
# not a single shared integer: a fiber's call stack is private to
|
|
29
|
+
# that fiber, and only one exception can be unwinding through a
|
|
30
|
+
# given fiber's stack at a time, so a fiber-local counter can
|
|
31
|
+
# never be perturbed by an unrelated raise on another fiber or
|
|
32
|
+
# thread. A single shared counter would not have that guarantee.
|
|
33
|
+
#
|
|
34
|
+
# `:rescue` requires ruby >= 3.3.0 (Feature #19572); this gem's
|
|
35
|
+
# required_ruby_version is >= 4.0, so no degrade path is needed
|
|
36
|
+
# here. `:rescue` only fires for Ruby-level `rescue` (ruby's own
|
|
37
|
+
# NEWS-3.3.0.md); a raise fully handled inside C code (measured:
|
|
38
|
+
# `Integer(s, exception: false)`) never fires `:raise` either, so
|
|
39
|
+
# it cannot unbalance the counter in the first place.
|
|
40
|
+
class RaiseTracker
|
|
41
|
+
COUNTER_KEY = :bulldogger_probe_raise_counter
|
|
42
|
+
STACK_KEY = :bulldogger_probe_raise_checkpoints
|
|
43
|
+
CLASS_KEY = :bulldogger_probe_raise_class
|
|
44
|
+
|
|
45
|
+
def self.instance
|
|
46
|
+
@instance ||= new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def initialize
|
|
50
|
+
@mutex = Mutex.new
|
|
51
|
+
@refcount = 0
|
|
52
|
+
@raise_tp = nil
|
|
53
|
+
@rescue_tp = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ref-counted: multiple probe sessions can be active at once
|
|
57
|
+
# (different targets, or nested probe calls) and all of them
|
|
58
|
+
# need this same pair of TracePoints running, but each pays only
|
|
59
|
+
# its own targeted :call/:return cost -- the untargeted :raise/
|
|
60
|
+
# :rescue subscription is shared, not duplicated per session.
|
|
61
|
+
def acquire
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@refcount += 1
|
|
64
|
+
next unless @refcount == 1
|
|
65
|
+
|
|
66
|
+
@raise_tp = TracePoint.new(:raise) { |tp| on_raise(tp) }
|
|
67
|
+
@rescue_tp = TracePoint.new(:rescue) { on_rescue }
|
|
68
|
+
@raise_tp.enable
|
|
69
|
+
@rescue_tp.enable
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def release
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
@refcount -= 1
|
|
76
|
+
next if @refcount.positive?
|
|
77
|
+
|
|
78
|
+
@raise_tp&.disable
|
|
79
|
+
@rescue_tp&.disable
|
|
80
|
+
@raise_tp = nil
|
|
81
|
+
@rescue_tp = nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Call at :call time, before any work that could raise inside
|
|
86
|
+
# the hook itself -- a cheap Array#push that must not be skipped,
|
|
87
|
+
# or the matching pop_and_raised_exit? at :return would read the
|
|
88
|
+
# wrong checkpoint and desynchronize every later call on this
|
|
89
|
+
# fiber.
|
|
90
|
+
def push_checkpoint
|
|
91
|
+
(Thread.current[STACK_KEY] ||= []) << counter
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Call at :return time. Pops the checkpoint pushed by the
|
|
95
|
+
# matching push_checkpoint (calls on one fiber nest properly, so
|
|
96
|
+
# a plain stack -- not a per-target slot -- keeps recursive and
|
|
97
|
+
# interleaved targets correctly paired) and reports whether the
|
|
98
|
+
# counter moved since that call started.
|
|
99
|
+
def pop_and_raised_exit?
|
|
100
|
+
stack = Thread.current[STACK_KEY]
|
|
101
|
+
checkpoint = stack && !stack.empty? ? stack.pop : 0
|
|
102
|
+
counter > checkpoint
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Only meaningful right after pop_and_raised_exit? returned
|
|
106
|
+
# true: the class of the most recent :raise seen on this fiber,
|
|
107
|
+
# which is the exception currently unwinding through the
|
|
108
|
+
# caller's frame. Stale otherwise; callers must gate on
|
|
109
|
+
# pop_and_raised_exit?, not read this unconditionally.
|
|
110
|
+
def current_exception_class_name
|
|
111
|
+
Thread.current[CLASS_KEY]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def counter
|
|
117
|
+
Thread.current[COUNTER_KEY] || 0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def on_raise(tp)
|
|
121
|
+
Thread.current[COUNTER_KEY] = counter + 1
|
|
122
|
+
Thread.current[CLASS_KEY] = exception_class_name(tp.raised_exception)
|
|
123
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def on_rescue
|
|
128
|
+
Thread.current[COUNTER_KEY] = counter - 1
|
|
129
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def exception_class_name(exception)
|
|
134
|
+
klass = exception.class
|
|
135
|
+
klass.respond_to?(:name) ? (klass.name || klass.to_s) : klass.to_s
|
|
136
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
137
|
+
"Object"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
module Probe
|
|
5
|
+
# Tracks which target labels currently have a live probe session,
|
|
6
|
+
# process-wide. Two concurrent sessions on the same method would
|
|
7
|
+
# each build their own TracePoint and double-count every call;
|
|
8
|
+
# the contract requires that be caught at start, by name, not
|
|
9
|
+
# discovered later as doubled statistics.
|
|
10
|
+
module Registry
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@active = {}
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def reserve!(labels)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
conflict = labels.find { |label| @active[label] }
|
|
18
|
+
if conflict
|
|
19
|
+
raise ArgumentError, "bulldogger: probe target #{conflict.inspect} is already being probed"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
labels.each { |label| @active[label] = true }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def release(labels)
|
|
27
|
+
@mutex.synchronize { labels.each { |label| @active.delete(label) } }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|