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, 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, **options)
  @name = name
  @supervisor_options = supervisor
  @operations = operations
  @options = 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

#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

#tmpdirObject (readonly)

Returns the value of attribute tmpdir.



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

def tmpdir
  @tmpdir
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



132
133
134
# File 'lib/hot_cell/test_cell.rb', line 132

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

#logObject



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_rootObject

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

#startObject

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.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.



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