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.
-
#tmpdir ⇒ Object
readonly
Returns the value of attribute tmpdir.
-
#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, own_tmpdir: true, **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, own_tmpdir: true, **options) ⇒ TestCell
Anything in supervisor: goes to Supervisor.new; everything else is the cell's own limits.
own_tmpdir: false boots the cell with no TMPDIR at all, the way a Procfile that sets none does.
49 50 51 52 53 54 55 56 57 58 59 60 61 |
# File 'lib/hot_cell/test_cell.rb', line 49 def initialize(name: "test", supervisor: {}, operations: nil, own_tmpdir: true, **) @name = name @supervisor_options = supervisor @operations = operations @options = @root = Dir.mktmpdir "hotcell-test" @directory = File.join(@root, name) # Boot empties `Dir.tmpdir` and the workspace's parent of what this uid owns. Inside the cell both are # this directory, so the sweep reaches neither the developer's `/tmp` nor the log and sockets beside it. @tmpdir = File.join(@root, "tmp") if own_tmpdir @workspace = File.join(@tmpdir, "workspace") if own_tmpdir @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 |
#tmpdir ⇒ Object (readonly)
Returns the value of attribute tmpdir.
32 33 34 |
# File 'lib/hot_cell/test_cell.rb', line 32 def tmpdir @tmpdir 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
132 133 134 |
# File 'lib/hot_cell/test_cell.rb', line 132 def cleanup FileUtils.remove_entry @root if Dir.exist?(@root) end |
#log ⇒ Object
136 137 138 |
# File 'lib/hot_cell/test_cell.rb', line 136 def log File.exist?(log_path) ? File.read(log_path) : "" end |
#log_events(event) ⇒ Object
140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/hot_cell/test_cell.rb', line 140 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.
64 65 66 |
# File 'lib/hot_cell/test_cell.rb', line 64 def socket_root @root end |
#start ⇒ Object
Writes a byte down a pipe once it is listening, so nothing here waits on a sleep.
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 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/hot_cell/test_cell.rb', line 69 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? if @tmpdir FileUtils.mkdir_p @tmpdir ENV["TMPDIR"] = @tmpdir else %w[ TMPDIR TMP TEMP ].each { |key| ENV.delete key } end 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.
119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/hot_cell/test_cell.rb', line 119 def stop return if @pid.nil? begin Process.kill :TERM, @pid wait_for_exit rescue Errno::ESRCH, Errno::ECHILD nil end @pid = nil end |