Class: HotCell::TestCell
- Inherits:
-
Object
- Object
- HotCell::TestCell
- 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
-
#directory ⇒ Object
readonly
Returns the value of attribute directory.
-
#log_path ⇒ Object
readonly
Returns the value of attribute log_path.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#workspace ⇒ Object
readonly
Returns the value of attribute workspace.
Class Method Summary collapse
Instance Method Summary collapse
- #cleanup ⇒ Object
-
#initialize(name: "test", supervisor: {}, operations: nil, **options) ⇒ TestCell
constructor
Anything in
supervisor:goes to Supervisor.new; everything else is the cell's own limits. - #log ⇒ Object
- #log_events(event) ⇒ Object
-
#socket_root ⇒ Object
The parent of the cell's own directory, which is what a client registers as HotCell.root.
-
#start ⇒ Object
Writes a byte down a pipe once it is listening, so nothing here waits on a sleep.
-
#stop ⇒ Object
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.
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, **) @name = name @supervisor_options = supervisor @operations = operations @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
#directory ⇒ Object (readonly)
Returns the value of attribute directory.
32 33 34 |
# File 'lib/hot_cell/test_cell.rb', line 32 def directory @directory end |
#log_path ⇒ Object (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 |
#name ⇒ Object (readonly)
Returns the value of attribute name.
32 33 34 |
# File 'lib/hot_cell/test_cell.rb', line 32 def name @name end |
#workspace ⇒ Object (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(**) new(**).start.tap do |cell| return cell unless block_given? begin yield cell ensure cell.stop cell.cleanup end end end |
Instance Method Details
#cleanup ⇒ Object
121 122 123 |
# File 'lib/hot_cell/test_cell.rb', line 121 def cleanup FileUtils.remove_entry @root if Dir.exist?(@root) end |
#log ⇒ Object
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_root ⇒ Object
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 |
#start ⇒ Object
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.}\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 |
#stop ⇒ Object
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 |