Class: SimpleCov::Production::FileSink

Inherits:
Object
  • Object
show all
Defined in:
lib/simplecov/production/file_sink.rb,
sig/simplecov.rbs

Overview

The bundled sink: a single JSON file, union-merged under an exclusive lock so any number of processes on the same host can share it. This is the reference for the sink contract:

store(coverage)  # {"lib/foo.rb" => [1, 3, 12], ...} of
               # root-relative paths to sorted line numbers

A sink must merge (never replace: many processes each hold only a slice), must be idempotent (the same lines may arrive twice), and signals failure by raising, which makes the runtime keep the delta and retry next interval.

last_seen stamps each file with the last store that carried it. Oneshot clears on drain, so still-running code re-reports every interval and the stamp tracks real recency, not first sighting. The field is optional on read: a v1 store written before it existed simply has no recency evidence to offer.

Constant Summary collapse

ENVELOPE =

The envelope key, which is also how read tells a production coverage file from an arbitrary JSON document it must not clobber or misread.

Returns:

  • (String)
"simplecov_production"
FORMAT_VERSION =

Returns:

  • (Integer)
1

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path:) ⇒ FileSink

Returns a new instance of FileSink.

Parameters:

  • path: (String)


35
36
37
# File 'lib/simplecov/production/file_sink.rb', line 35

def initialize(path:)
  @path = File.expand_path(path)
end

Instance Attribute Details

#pathString (readonly)

Returns the value of attribute path.

Returns:

  • (String)


33
34
35
# File 'lib/simplecov/production/file_sink.rb', line 33

def path
  @path
end

Class Method Details

.envelope_of(document, path) ⇒ Hash[String, untyped]

The refusal is what keeps an arbitrary JSON file from being misread as empty coverage.

Parameters:

  • document (Object)
  • path (String)

Returns:

  • (Hash[String, untyped])

Raises:



81
82
83
84
85
86
# File 'lib/simplecov/production/file_sink.rb', line 81

def self.envelope_of(document, path)
  inner = document[ENVELOPE] if document.instance_of?(Hash)
  raise Error, "#{path} is not a SimpleCov production coverage file" unless inner.instance_of?(Hash)

  inner
end

.parse(content, path) ⇒ Hash[String, untyped]

empty or missing file is a fresh store. JSON answers plain hashes, arrays, and scalars, never a subclass of one, so the shape checks ask about the class itself.

Parameters:

  • content (String)
  • path (String)

Returns:

  • (Hash[String, untyped])


66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/simplecov/production/file_sink.rb', line 66

def self.parse(content, path)
  return {"coverage" => {}, "last_seen" => {}, "started_at" => nil} if content.match?(/\A\s*\z/)

  inner = envelope_of(JSON.parse(content), path)
  inner["coverage"] = {} unless inner["coverage"].instance_of?(Hash)
  inner["last_seen"] = {} unless inner["last_seen"].instance_of?(Hash)
  inner
rescue JSON::ParserError => e
  # One line of the parser's complaint, which for some inputs quotes the
  # document back and runs long.
  raise Error, "#{path} is not valid JSON (#{e.message.lines.first.to_s.rstrip})"
end

.read(path) ⇒ Hash[String, untyped]

Raises Error for anything that is not a production coverage file, naming the path: misreading an arbitrary JSON file as empty coverage would quietly report every line dead.

Parameters:

  • path (String)

Returns:

  • (Hash[String, untyped])


58
59
60
# File 'lib/simplecov/production/file_sink.rb', line 58

def self.read(path)
  parse(File.read(path), path)
end

Instance Method Details

#envelope(existing, incoming) ⇒ Hash[String, untyped]

Parameters:

  • existing (Hash[String, untyped])
  • incoming (Hash[String, Array[Integer]])

Returns:

  • (Hash[String, untyped])


116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/simplecov/production/file_sink.rb', line 116

def envelope(existing, incoming)
  now = Time.now.utc.iso8601
  {
    ENVELOPE => {
      "format_version" => FORMAT_VERSION,
      "started_at" => existing["started_at"] || now,
      "updated_at" => now,
      "coverage" => merge(existing.fetch("coverage"), incoming).sort.to_h,
      "last_seen" => existing.fetch("last_seen").merge(incoming.transform_values { now }).sort.to_h
    }
  }
end

#merge(existing, incoming) ⇒ Hash[String, untyped]

Parameters:

  • existing (Hash[String, untyped])
  • incoming (Hash[String, Array[Integer]])

Returns:

  • (Hash[String, untyped])


110
111
112
113
114
# File 'lib/simplecov/production/file_sink.rb', line 110

def merge(existing, incoming)
  incoming.each_with_object(existing) do |(file, lines), merged|
    merged[file] = ((merged[file] || []) | lines).sort
  end
end

#open_file(name, mode, perm) ⇒ void

This method returns an undefined value.

mutant:disable — Ruby 4.0 removed Kernel#open's leading-pipe command mode, so no test can tell File.open from open here any more. The explicit receiver is kept: on older rubies it is what refuses to run a store path as a command.



106
107
108
# File 'lib/simplecov/production/file_sink.rb', line 106

def open_file(name, mode, perm, &)
  File.open(name, mode, perm, &)
end

#rewrite(file, payload) ⇒ void

This method returns an undefined value.

Truncates any leftover tail from a previously larger document.

Parameters:

  • file (File)
  • payload (Hash[String, untyped])


49
50
51
52
53
# File 'lib/simplecov/production/file_sink.rb', line 49

def rewrite(file, payload)
  file.rewind
  file.write(JSON.generate(payload))
  file.truncate(file.pos)
end

#store(coverage) ⇒ true

Parameters:

  • coverage (Hash[String, Array[Integer]])

Returns:

  • (true)


39
40
41
42
43
44
45
46
# File 'lib/simplecov/production/file_sink.rb', line 39

def store(coverage)
  FileUtils.mkdir_p(File.dirname(path))
  with_exclusive_lock do |file|
    existing = self.class.parse(file.read, path)
    rewrite(file, envelope(existing, coverage))
  end
  true
end

#with_exclusive_lockvoid

This method returns an undefined value.

Both halves of the read-modify-write happen through the one handle, so processes sharing the file take turns rather than overwriting each other. The open flags are summed, which for disjoint bits is the same number OR would build and leaves no spelling of the combination without a witness.



95
96
97
98
99
100
# File 'lib/simplecov/production/file_sink.rb', line 95

def with_exclusive_lock
  open_file(path, File::RDWR + File::CREAT, 0o644) do |file|
    file.flock(File::LOCK_EX)
    yield file
  end
end