Class: HotCell::TestCell

Inherits:
Object
  • Object
show all
Defined in:
lib/hot_cell/test_cell.rb

Overview

A real cell in a real process, for anybody's test suite.

This ships here rather than being written again by each consumer, because every consumer otherwise writes its own stub cell and they drift. It is not a stub: it forks, it passes descriptors, it applies limits, and it reaps — none of which an in-process double would exercise, and all of which is where the interesting failures are.

The operations it carries are whatever the calling process has defined, because a worker inherits the registry through the fork, exactly as a real cell does at boot.

HotCell::TestCell.boot(concurrency: 2) do |cell|
HotCell.root = File.dirname(cell.directory)
...
end

Some operations cannot be loaded in a test process at all, and libvips is the reason this matters: its thread pool does not survive a fork, so a suite that required it before booting a cell would make every worker deadlock. Pass operations: a callable and it runs inside the cell's own process, before it boots — which is the only place such a library may be loaded.

HotCell::TestCell.boot(operations: -> { require "active_storage/hot_cell/server" })

Constant Summary collapse

READY =
"up"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name: "test", supervisor: {}, operations: nil, **options) ⇒ TestCell

Anything in supervisor: goes to Supervisor.new; everything else is the cell's own limits.



48
49
50
51
52
53
54
55
56
57
# File 'lib/hot_cell/test_cell.rb', line 48

def initialize(name: "test", supervisor: {}, operations: nil, **options)
  @name = name
  @supervisor_options = supervisor
  @operations = operations
  @options = options
  @root = Dir.mktmpdir "hotcell-test"
  @directory = File.join(@root, name)
  @workspace = File.join(@root, "workspace")
  @log_path = File.join(@root, "cell.log")
end

Instance Attribute Details

#directoryObject (readonly)

Returns the value of attribute directory.



32
33
34
# File 'lib/hot_cell/test_cell.rb', line 32

def directory
  @directory
end

#log_pathObject (readonly)

Returns the value of attribute log_path.



32
33
34
# File 'lib/hot_cell/test_cell.rb', line 32

def log_path
  @log_path
end

#nameObject (readonly)

Returns the value of attribute name.



32
33
34
# File 'lib/hot_cell/test_cell.rb', line 32

def name
  @name
end

#workspaceObject (readonly)

Returns the value of attribute workspace.



32
33
34
# File 'lib/hot_cell/test_cell.rb', line 32

def workspace
  @workspace
end

Class Method Details

.boot(**options) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/hot_cell/test_cell.rb', line 34

def self.boot(**options)
  new(**options).start.tap do |cell|
    return cell unless block_given?

    begin
      yield cell
    ensure
      cell.stop
      cell.cleanup
    end
  end
end

Instance Method Details

#cleanupObject



121
122
123
# File 'lib/hot_cell/test_cell.rb', line 121

def cleanup
  FileUtils.remove_entry @root if Dir.exist?(@root)
end

#logObject



125
126
127
# File 'lib/hot_cell/test_cell.rb', line 125

def log
  File.exist?(log_path) ? File.read(log_path) : ""
end

#log_events(event) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
# File 'lib/hot_cell/test_cell.rb', line 129

def log_events(event)
  log.lines.filter_map do |line|
    parsed = begin
      JSON.parse line, symbolize_names: true
    rescue JSON::ParserError
      nil
    end

    parsed if parsed.is_a?(Hash) && parsed[:event].is_a?(Hash) && parsed[:event][:action] == event
  end
end

#socket_rootObject

The parent of the cell's own directory, which is what a client registers as HotCell.root.



60
61
62
# File 'lib/hot_cell/test_cell.rb', line 60

def socket_root
  @root
end

#startObject

Writes a byte down a pipe once it is listening, so nothing here waits on a sleep.



65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/hot_cell/test_cell.rb', line 65

def start
  reader, writer = IO.pipe

  @pid = fork do
    reader.close

    # The cell must not hold the test runner's stdout. A cell that outlives its test would otherwise keep
    # the runner's pipe open, and a failing assertion becomes a hang rather than a failure.
    $stdout.reopen log_path, "a"
    $stderr.reopen log_path, "a"

    HotCell.limits(**@options) unless @options.empty?

    supervisor = Supervisor.new(directory: directory, workspace: workspace,
                                log: Log.new(File.open(log_path, "w")), **@supervisor_options)
    begin
      @operations&.call
      supervisor.boot
      writer.write READY
      writer.close
      supervisor.run
    # StandardError is enough. What must not happen is an exception escaping this block, because Ruby would
    # then run at_exit in the child — including minitest's autorun, which starts the whole suite over inside
    # a forked cell. The `ensure exit!` below is what prevents that, for anything raised. This rescue only
    # writes the diagnostic the parent reads.
    rescue StandardError => error
      File.write log_path, "#{error.class}: #{error.message}\n" \
                           "#{error.backtrace&.first(10)&.join("\n")}\n", mode: "a"
    ensure
      exit! 0
    end
  end

  writer.close
  ready = reader.read(READY.bytesize)
  reader.close
  raise "the cell did not boot: #{log}" unless ready == READY

  self
end

#stopObject

Terminates the cell and leaves its files, so a test can read the log once everything that was going to write to it has exited.



108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/hot_cell/test_cell.rb', line 108

def stop
  return if @pid.nil?

  begin
    Process.kill :TERM, @pid
    wait_for_exit
  rescue Errno::ESRCH, Errno::ECHILD
    nil
  end

  @pid = nil
end