fast_cov 0.4.1 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 450398ffd460598ce2a3e8738705d3b36cf40763961139d88f6a003d86002c27
4
- data.tar.gz: c80e01acd939edabf2eb811b8198af749f378be8ca6e3c6819238b5b7bd3aae5
3
+ metadata.gz: f05621dfa93b2807fd7060732bd323ddd407e9af1d80b0857d5d50d6c8667a24
4
+ data.tar.gz: f0cc0a9afd99fddac4e0cc263c0dc5edefb373e5da1b145f12148c2a3640aa39
5
5
  SHA512:
6
- metadata.gz: c5b95fce00edd7076b5be23fce0847f597ec6feda69fb0d66ebfb5af5d2de41aa47a040cf2b11d3ce7bf13fb91dd64f6da48a13beaaf29307e180c76a81ee0e9
7
- data.tar.gz: 455f38ef134a86d3e2c37524d52958d23a47e1e6b257a15fef2174b846b761b667ee457c794967fede777fb7bdef9fbe960a554109db5dde93aa2f9b8ea8c807
6
+ metadata.gz: 8736b3e941eb96518a55ff674429610cb429e790a4fcf67a808d5b5830d3f5110ce443ab18a8e15b657a74a4f3f69734121c8bb924b14b93f0b12eddc00745b2
7
+ data.tar.gz: cc856dc60b6d55b534982b1cb83e6bbf8f5b699ff7f1bbd39b9f96749a119261880ec010243a72cbddb00c535a6e47bd0582546a5288ea6370bfa89a43eea582
@@ -8,5 +8,6 @@ end
8
8
 
9
9
  require "mkmf"
10
10
 
11
- # Version-tagged so multiple Ruby versions can coexist in development
12
- create_makefile("fast_cov/fast_cov.#{RUBY_VERSION}")
11
+ # Tagged with the ABI version so multiple Ruby versions can coexist in
12
+ # development. Must stay in sync with the require in lib/fast_cov.rb.
13
+ create_makefile("fast_cov/fast_cov.#{RbConfig::CONFIG["ruby_version"]}")
@@ -17,6 +17,13 @@
17
17
  static VALUE fast_cov_stop(VALUE self);
18
18
  static VALUE fast_cov_yield_block(VALUE _arg);
19
19
 
20
+ // Seen-set sizing. Power-of-two capacities so lookups mask instead of modulo.
21
+ #define SEEN_INITIAL_CAPACITY 256
22
+ // Grow at 3/4 load. Linear probing degrades sharply past that, and a full
23
+ // table would make the insert probe loop spin forever.
24
+ #define SEEN_LOAD_NUMERATOR 3
25
+ #define SEEN_LOAD_DENOMINATOR 4
26
+
20
27
  // ---- Data structure -----------------------------------------------------
21
28
 
22
29
  struct fast_cov_data {
@@ -29,8 +36,20 @@ struct fast_cov_data {
29
36
  long *ignored_path_lens;
30
37
  long ignored_paths_count;
31
38
 
39
+ // Two-level cache over source file identity, both keyed on the pointer
40
+ // rb_sourcefile() returns (stable per file, so comparing it is one
41
+ // integer compare instead of a string compare):
42
+ //
43
+ // last_filename_ptr - single slot, hits while execution stays in one file
44
+ // seen_* - open-addressed set of every file seen this session,
45
+ // so alternating between files stays on the fast path
32
46
  uintptr_t last_filename_ptr;
33
47
 
48
+ uintptr_t *seen_ptrs;
49
+ VALUE *seen_paths;
50
+ long seen_capacity;
51
+ long seen_count;
52
+
34
53
  bool threads;
35
54
  bool started;
36
55
  VALUE th_covered;
@@ -44,8 +63,18 @@ struct fast_cov_data {
44
63
 
45
64
  static void fast_cov_mark(void *ptr) {
46
65
  struct fast_cov_data *data = ptr;
66
+ long i;
67
+
47
68
  rb_gc_mark(data->impacted_files);
48
69
  rb_gc_mark(data->th_covered);
70
+
71
+ // Pinning the path strings is what makes the pointer cache sound: it keeps
72
+ // each rb_sourcefile() pointer alive and at a fixed address for the whole
73
+ // session. Without it a freed string's address could be reused by another
74
+ // file, which would read as a cache hit and silently drop that file.
75
+ for (i = 0; i < data->seen_capacity; i++) {
76
+ if (data->seen_ptrs[i]) rb_gc_mark(data->seen_paths[i]);
77
+ }
49
78
  }
50
79
 
51
80
  static void fast_cov_free(void *ptr) {
@@ -59,6 +88,8 @@ static void fast_cov_free(void *ptr) {
59
88
  xfree(data->ignored_paths);
60
89
  }
61
90
  if (data->ignored_path_lens) xfree(data->ignored_path_lens);
91
+ if (data->seen_ptrs) xfree(data->seen_ptrs);
92
+ if (data->seen_paths) xfree(data->seen_paths);
62
93
  xfree(data);
63
94
  }
64
95
 
@@ -92,15 +123,111 @@ static VALUE fast_cov_allocate(VALUE klass) {
92
123
  data->threads = true;
93
124
  data->started = false;
94
125
 
126
+ // Keep seen_capacity at 0 until both arrays are installed: xcalloc can
127
+ // trigger GC, and fast_cov_mark walks seen_capacity entries.
128
+ data->seen_capacity = 0;
129
+ data->seen_count = 0;
130
+ data->seen_ptrs = NULL;
131
+ data->seen_paths = NULL;
132
+
133
+ uintptr_t *seen_ptrs = xcalloc(SEEN_INITIAL_CAPACITY, sizeof(uintptr_t));
134
+ VALUE *seen_paths = xcalloc(SEEN_INITIAL_CAPACITY, sizeof(VALUE));
135
+ data->seen_ptrs = seen_ptrs;
136
+ data->seen_paths = seen_paths;
137
+ data->seen_capacity = SEEN_INITIAL_CAPACITY;
138
+
95
139
  return obj;
96
140
  }
97
141
 
142
+ // ---- Seen-set -----------------------------------------------------------
143
+ //
144
+ // Open-addressed set of the rb_sourcefile() pointers seen this session, with
145
+ // the corresponding path string stored alongside so it can be pinned. Linear
146
+ // probing keeps lookups in one cache line for the common case.
147
+
148
+ // The low bits of a pointer carry little entropy (allocations are aligned),
149
+ // so shift them off before masking.
150
+ static inline long seen_slot(uintptr_t filename_ptr, long capacity) {
151
+ return (long)((filename_ptr >> 3) & (uintptr_t)(capacity - 1));
152
+ }
153
+
154
+ static inline bool seen_include(const struct fast_cov_data *data,
155
+ uintptr_t filename_ptr) {
156
+ long slot = seen_slot(filename_ptr, data->seen_capacity);
157
+
158
+ while (data->seen_ptrs[slot]) {
159
+ if (data->seen_ptrs[slot] == filename_ptr) return true;
160
+ slot = (slot + 1) & (data->seen_capacity - 1);
161
+ }
162
+
163
+ return false;
164
+ }
165
+
166
+ static void seen_grow(struct fast_cov_data *data) {
167
+ uintptr_t *old_ptrs = data->seen_ptrs;
168
+ VALUE *old_paths = data->seen_paths;
169
+ long old_capacity = data->seen_capacity;
170
+ long new_capacity = old_capacity * 2;
171
+ long i;
172
+
173
+ // Allocate both arrays before installing either. GC can run inside xcalloc,
174
+ // and fast_cov_mark must keep seeing a consistent capacity/arrays triple.
175
+ uintptr_t *new_ptrs = xcalloc(new_capacity, sizeof(uintptr_t));
176
+ VALUE *new_paths = xcalloc(new_capacity, sizeof(VALUE));
177
+
178
+ for (i = 0; i < old_capacity; i++) {
179
+ uintptr_t filename_ptr = old_ptrs[i];
180
+ if (!filename_ptr) continue;
181
+
182
+ long slot = seen_slot(filename_ptr, new_capacity);
183
+ while (new_ptrs[slot]) {
184
+ slot = (slot + 1) & (new_capacity - 1);
185
+ }
186
+ new_ptrs[slot] = filename_ptr;
187
+ new_paths[slot] = old_paths[i];
188
+ }
189
+
190
+ // Install, then free: the struct must never point at freed arrays, since a
191
+ // GC between the two would mark through them.
192
+ data->seen_ptrs = new_ptrs;
193
+ data->seen_paths = new_paths;
194
+ data->seen_capacity = new_capacity;
195
+
196
+ xfree(old_ptrs);
197
+ xfree(old_paths);
198
+ }
199
+
200
+ static void seen_add(struct fast_cov_data *data, uintptr_t filename_ptr,
201
+ VALUE path) {
202
+ if (data->seen_count + 1 >
203
+ data->seen_capacity * SEEN_LOAD_NUMERATOR / SEEN_LOAD_DENOMINATOR) {
204
+ seen_grow(data);
205
+ }
206
+
207
+ long slot = seen_slot(filename_ptr, data->seen_capacity);
208
+ while (data->seen_ptrs[slot]) {
209
+ if (data->seen_ptrs[slot] == filename_ptr) return;
210
+ slot = (slot + 1) & (data->seen_capacity - 1);
211
+ }
212
+
213
+ data->seen_ptrs[slot] = filename_ptr;
214
+ data->seen_paths[slot] = path;
215
+ data->seen_count++;
216
+ }
217
+
218
+ static void seen_clear(struct fast_cov_data *data) {
219
+ MEMZERO(data->seen_ptrs, uintptr_t, data->seen_capacity);
220
+ MEMZERO(data->seen_paths, VALUE, data->seen_capacity);
221
+ data->seen_count = 0;
222
+ }
223
+
98
224
  // ---- Internal helpers ---------------------------------------------------
99
225
 
100
226
  static bool record_impacted_file(struct fast_cov_data *data, VALUE filename) {
101
- if (!fast_cov_is_path_included(RSTRING_PTR(filename), data->root,
102
- data->root_len, data->ignored_paths,
103
- data->ignored_path_lens,
227
+ // RSTRING_LEN is O(1); passing it avoids re-scanning the path with strlen.
228
+ if (!fast_cov_is_path_included(RSTRING_PTR(filename), RSTRING_LEN(filename),
229
+ data->root, data->root_len,
230
+ data->ignored_paths, data->ignored_path_lens,
104
231
  data->ignored_paths_count)) {
105
232
  return false;
106
233
  }
@@ -127,6 +254,13 @@ static void on_line_event(rb_event_flag_t event, VALUE self_data, VALUE self,
127
254
  }
128
255
  data->last_filename_ptr = current_filename_ptr;
129
256
 
257
+ // Execution alternates between files constantly (a method in one file
258
+ // calling into another), so the single slot above misses often. Anything
259
+ // already seen this session is resolved here without touching the VM.
260
+ if (seen_include(data, current_filename_ptr)) {
261
+ return;
262
+ }
263
+
130
264
  VALUE top_frame;
131
265
  if (rb_profile_frames(0, 1, &top_frame, NULL) != 1) {
132
266
  return;
@@ -137,6 +271,9 @@ static void on_line_event(rb_event_flag_t event, VALUE self_data, VALUE self,
137
271
  return;
138
272
  }
139
273
 
274
+ // Only cache pointers we hold a path string for — the pin in fast_cov_mark
275
+ // is what keeps the pointer valid and unambiguous.
276
+ seen_add(data, current_filename_ptr, filename);
140
277
  record_impacted_file(data, filename);
141
278
  }
142
279
 
@@ -265,16 +402,22 @@ static VALUE fast_cov_stop(VALUE self) {
265
402
  if (thval != data->th_covered) {
266
403
  rb_raise(rb_eRuntimeError, "Coverage was not started by this thread");
267
404
  }
268
- rb_thread_remove_event_hook(data->th_covered, on_line_event);
405
+ // Match on the registering object, not just the callback. The plain
406
+ // rb_*_remove_event_hook variants match by function pointer alone, so
407
+ // stopping one Coverage instance would tear down the hooks belonging to
408
+ // every other live instance — silently, since those instances stay
409
+ // `started` and simply stop recording.
410
+ rb_thread_remove_event_hook_with_data(data->th_covered, on_line_event, self);
269
411
  data->th_covered = Qnil;
270
412
  } else {
271
- rb_remove_event_hook(on_line_event);
413
+ rb_remove_event_hook_with_data(on_line_event, self);
272
414
  }
273
415
 
274
416
  VALUE res = data->impacted_files;
275
417
 
276
418
  data->impacted_files = rb_hash_new();
277
419
  data->last_filename_ptr = 0;
420
+ seen_clear(data);
278
421
  data->started = false;
279
422
 
280
423
  return res;
@@ -9,9 +9,9 @@
9
9
  bool fast_cov_is_within_root(const char *path, long path_len,
10
10
  const char *root, long root_len);
11
11
 
12
- bool fast_cov_is_path_included(const char *path, const char *root_path,
13
- long root_path_len, char **ignored_paths,
14
- long *ignored_path_lens,
12
+ bool fast_cov_is_path_included(const char *path, long path_len,
13
+ const char *root_path, long root_path_len,
14
+ char **ignored_paths, long *ignored_path_lens,
15
15
  long ignored_paths_count);
16
16
 
17
17
  /* ---- Utility functions -------------------------------------------------- */
@@ -18,8 +18,9 @@ bool fast_cov_is_within_root(const char *path, long path_len,
18
18
  return false;
19
19
  }
20
20
 
21
- // Check prefix match
22
- if (strncmp(path, root, effective_root_len) != 0) {
21
+ // Check prefix match. memcmp rather than strncmp: both lengths are known,
22
+ // so there is no reason to also scan for a terminator.
23
+ if (memcmp(path, root, (size_t)effective_root_len) != 0) {
23
24
  return false;
24
25
  }
25
26
 
@@ -33,11 +34,10 @@ bool fast_cov_is_within_root(const char *path, long path_len,
33
34
  return path[effective_root_len] == '/';
34
35
  }
35
36
 
36
- bool fast_cov_is_path_included(const char *path, const char *root_path,
37
- long root_path_len, char **ignored_paths,
38
- long *ignored_path_lens,
37
+ bool fast_cov_is_path_included(const char *path, long path_len,
38
+ const char *root_path, long root_path_len,
39
+ char **ignored_paths, long *ignored_path_lens,
39
40
  long ignored_paths_count) {
40
- long path_len = (long)strlen(path);
41
41
  long i;
42
42
 
43
43
  if (!fast_cov_is_within_root(path, path_len, root_path, root_path_len)) {
@@ -48,6 +48,20 @@ module FastCov
48
48
  cov.stop
49
49
  end
50
50
 
51
+ # Every call crosses calculator.rb -> operations/*.rb and back, so this
52
+ # measures the per-line-event cost of file transitions rather than the
53
+ # start/stop overhead that dominates the scenarios above.
54
+ runner.scenario("Line coverage (cross-file transitions)") do
55
+ cov = FastCov::Coverage.new(root: root_calculator)
56
+ cov.start
57
+ 200.times do
58
+ calculator.add(1, 2)
59
+ calculator.subtract(3, 1)
60
+ calculator.multiply(2, 3)
61
+ end
62
+ cov.stop
63
+ end
64
+
51
65
  runner.scenario("Rapid start/stop (100x)") do
52
66
  cov = FastCov::Coverage.new(root: root_calculator)
53
67
  100.times do
@@ -41,8 +41,11 @@ module FastCov
41
41
  stored != source_digest
42
42
  end
43
43
 
44
+ # Matches the ABI-version tag used by extconf.rb and lib/fast_cov.rb.
45
+ ABI_VERSION = RbConfig::CONFIG["ruby_version"]
46
+
44
47
  def self.extension_exists?
45
- Dir.glob(File.join(FAST_COV_DIR, "fast_cov.#{RUBY_VERSION}.{bundle,so}")).any?
48
+ Dir.glob(File.join(FAST_COV_DIR, "fast_cov.#{ABI_VERSION}.{bundle,so}")).any?
46
49
  end
47
50
 
48
51
  def self.source_digest
@@ -52,8 +55,8 @@ module FastCov
52
55
  end
53
56
 
54
57
  def self.digest_path
55
- # Keep version-specific so we recompile when switching Ruby versions
56
- File.join(FAST_COV_DIR, ".source_digest.#{RUBY_VERSION}")
58
+ # Keep ABI-specific so we recompile when switching Ruby versions
59
+ File.join(FAST_COV_DIR, ".source_digest.#{ABI_VERSION}")
57
60
  end
58
61
 
59
62
  def self.write_digest
@@ -16,6 +16,7 @@ module FastCov
16
16
  @connected_dependencies = ConnectedDependencies.new
17
17
  @trackers = []
18
18
  @native_coverage = nil
19
+ @native_coverage_config = nil
19
20
  @started = false
20
21
  end
21
22
 
@@ -69,11 +70,7 @@ module FastCov
69
70
  return self if @started
70
71
 
71
72
  begin
72
- @native_coverage = Coverage.new(
73
- root: normalized_root,
74
- ignored_paths: normalized_ignored_paths,
75
- threads: @threads != false
76
- )
73
+ @native_coverage = native_coverage
77
74
  @native_coverage.start
78
75
  @trackers.each(&:start)
79
76
  @started = true
@@ -93,7 +90,6 @@ module FastCov
93
90
  @connected_dependencies.expand(result)
94
91
  Utils.relativize_paths(result, normalized_root)
95
92
  ensure
96
- @native_coverage = nil
97
93
  @started = false
98
94
  end
99
95
 
@@ -117,6 +113,20 @@ module FastCov
117
113
 
118
114
  private
119
115
 
116
+ # Reused across cycles: a Coverage instance rebuilds its file cache from
117
+ # scratch on construction, so a fresh one per cycle discards that work.
118
+ def native_coverage
119
+ config = [normalized_root, normalized_ignored_paths, @threads != false]
120
+ return @native_coverage if @native_coverage && @native_coverage_config == config
121
+
122
+ @native_coverage_config = config
123
+ @native_coverage = Coverage.new(
124
+ root: config[0],
125
+ ignored_paths: config[1],
126
+ threads: config[2]
127
+ )
128
+ end
129
+
120
130
  def normalized_root
121
131
  path = @root&.to_s
122
132
  raise ConfigurationError, "root is required" if path.nil? || path.empty?
@@ -145,10 +155,12 @@ module FastCov
145
155
  File.absolute_path?(path)
146
156
  end
147
157
 
158
+ # Against root, not Dir.pwd: #stop returns root-relative paths and callers
159
+ # pass them straight back into #connect.
148
160
  def normalize_path(path)
149
161
  return if path.nil?
150
162
 
151
- File.expand_path(path.to_s)
163
+ File.expand_path(path.to_s, normalized_root)
152
164
  end
153
165
 
154
166
  def cleanup_failed_start
@@ -53,8 +53,11 @@ module FastCov
53
53
  def dump(path)
54
54
  FileUtils.mkdir_p(File.dirname(path))
55
55
 
56
- lines = @mapping.map { |file, deps| "#{file}\t#{deps.to_a.join("\t")}\n" }
57
- Zlib::GzipWriter.open(path) { |gz| gz.write(lines.join) }
56
+ Zlib::GzipWriter.open(path) do |gz|
57
+ @mapping.each do |file, deps|
58
+ gz.write("#{file}\t#{deps.to_a.join("\t")}\n")
59
+ end
60
+ end
58
61
  end
59
62
 
60
63
  # Number of unique source files mapped.
@@ -24,7 +24,13 @@ module FastCov
24
24
  module ConstGetPatch
25
25
  def const_get(name, inherit = true)
26
26
  result = super
27
- FastCov::ConstGetTracker.record(const_source_location(name, inherit)&.first)
27
+
28
+ # The patch is permanent, so the active check has to precede the
29
+ # lookup — otherwise every const_get in the process pays for it.
30
+ if FastCov::ConstGetTracker.active
31
+ FastCov::ConstGetTracker.record(const_source_location(name, inherit)&.first)
32
+ end
33
+
28
34
  result
29
35
  end
30
36
  end
@@ -21,19 +21,23 @@ module FastCov
21
21
  end
22
22
  end
23
23
 
24
+ # The patches are permanent, so each checks active before doing any work.
25
+ # Paths go over raw because AbstractTracker#record normalizes them.
24
26
  module FilePatch
25
27
  def read(name, *args, **kwargs, &block)
26
28
  super.tap do
27
- FastCov::FileTracker.record(File.expand_path(name))
29
+ FastCov::FileTracker.record(name) if FastCov::FileTracker.active
28
30
  end
29
31
  end
30
32
 
31
33
  def open(name, *args, **kwargs, &block)
32
- mode = args[0]
33
- is_read = mode.nil? || (mode.is_a?(String) && mode.start_with?("r")) ||
34
- (mode.is_a?(Integer) && (mode & (File::WRONLY | File::RDWR)).zero?)
35
34
  super.tap do
36
- FastCov::FileTracker.record(File.expand_path(name)) if is_read
35
+ next unless FastCov::FileTracker.active
36
+
37
+ mode = args[0]
38
+ is_read = mode.nil? || (mode.is_a?(String) && mode.start_with?("r")) ||
39
+ (mode.is_a?(Integer) && (mode & (File::WRONLY | File::RDWR)).zero?)
40
+ FastCov::FileTracker.record(name) if is_read
37
41
  end
38
42
  end
39
43
  end
@@ -41,19 +45,19 @@ module FastCov
41
45
  module YamlPatch
42
46
  def load_file(path, *args, **kwargs)
43
47
  super.tap do
44
- FastCov::FileTracker.record(File.expand_path(path))
48
+ FastCov::FileTracker.record(path) if FastCov::FileTracker.active
45
49
  end
46
50
  end
47
51
 
48
52
  def safe_load_file(path, *args, **kwargs)
49
53
  super.tap do
50
- FastCov::FileTracker.record(File.expand_path(path))
54
+ FastCov::FileTracker.record(path) if FastCov::FileTracker.active
51
55
  end
52
56
  end
53
57
 
54
58
  def unsafe_load_file(path, *args, **kwargs)
55
59
  super.tap do
56
- FastCov::FileTracker.record(File.expand_path(path))
60
+ FastCov::FileTracker.record(path) if FastCov::FileTracker.active
57
61
  end
58
62
  end
59
63
  end
@@ -41,6 +41,8 @@ module FastCov
41
41
  # When a test mounts a fixture, record the fixture definition file
42
42
  # and any parent fixture files in the chain.
43
43
  config.on_cache_mount do |event|
44
+ next unless tracker.class.active
45
+
44
46
  tracker.class.record(event.path)
45
47
  parent = event.fixture.parent
46
48
  while parent
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastCov
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/fast_cov.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fast_cov/fast_cov.#{RUBY_VERSION}"
3
+ require "rbconfig"
4
+
5
+ # ABI version ("3.4.0"), not RUBY_VERSION ("3.4.9"): RubyGems keys installed
6
+ # extensions by ABI, so it will not rebuild the gem on a patch-level upgrade.
7
+ require "fast_cov/fast_cov.#{RbConfig::CONFIG["ruby_version"]}"
4
8
 
5
9
  module FastCov
6
10
  autoload :Utils, File.expand_path("fast_cov/utils", __dir__)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_cov
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ngan Pham