hotcell-server 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0353cf7589212280f099039e6bec97f6da765b1d0c7442fe3183de5ba9e8077
4
- data.tar.gz: 588b3f6a12b02dc00b096843d61335291d7bf62590337c2f52487df759f4ef05
3
+ metadata.gz: b69f25f352406df26617692f42c1ce78c7191df4d7b617360c62c2371e488bed
4
+ data.tar.gz: 34ca8c1eea5a9de3cf739ce548151ac5c9487e185739f684d179844c13257b1d
5
5
  SHA512:
6
- metadata.gz: a15cb0b8e9d399a3a37659e7008b3c717337ea14beb6a13ad44e4490cb4faedf59aed7520b9abc265b4c762fdbd7d624a32632886470f915925e183739178496
7
- data.tar.gz: fffde7009ac88742aa9694585203de60cee1da4e637821f851eff453f67d9beaa52305049dfe1882373c396b92c933cf84bde9cf8b0fbd4850295a5ea7b3d020
6
+ metadata.gz: 5667ac9be32cd60ab131df56fb4be0a0354f007a19d31990c6210076dcd6e72c019a903b8e4f4f89ed5bf4ee444269de2b30d4776aa6373750f02ab21fa2869b
7
+ data.tar.gz: 9567d005d357239081a4c804b081939714c1ead9afb2588fcbd95d6795c638770348959d00549d49d9b76ec7cdc7bb0e3b2f7dd226a1a001d0c532fb524a05d2
data/exe/hotcell CHANGED
@@ -3,9 +3,13 @@
3
3
 
4
4
  require "hot_cell/server"
5
5
 
6
+ development = ARGV.delete("--development") ? true : false
7
+ abort "usage: hotcell [--development]" unless ARGV.empty?
8
+
6
9
  HotCell.load!
7
10
 
8
11
  HotCell::Supervisor
9
- .new(directory: ENV.fetch("HOTCELL_DIR", "/run/hotcell/cell"), workspace: ENV["HOTCELL_WORKSPACE"])
12
+ .new(directory: ENV.fetch("HOTCELL_DIR", "/run/hotcell/cell"), workspace: ENV["HOTCELL_WORKSPACE"],
13
+ development: development)
10
14
  .boot
11
15
  .run
@@ -23,6 +23,7 @@ module HotCell
23
23
  queue_wait: 10, # seconds a queued connection may wait before it is answered `capacity`
24
24
  max_requests_per_worker: 1, # requests a worker serves before it is discarded
25
25
  control_deadline: 5, # seconds a control connection may take to send its request
26
+ sweep_interval: 10, # seconds between the supervisor's checks for a killed request's tree to unlink
26
27
  }.freeze
27
28
 
28
29
  LIMITS = {
@@ -51,6 +52,7 @@ module HotCell
51
52
  # in describe's JSON, and they may arrive as Active Support durations.
52
53
  @queue_wait = @queue_wait.to_f
53
54
  @control_deadline = @control_deadline.to_f
55
+ @sweep_interval = @sweep_interval.to_f
54
56
 
55
57
  # A nil is not "use the default" here, it is a missing number. A cell whose deadline is nil accepts
56
58
  # every request and then dies on the first arithmetic the supervisor does with it, so an explicit nil
@@ -103,6 +105,7 @@ module HotCell
103
105
  positive! :concurrency, integer: true
104
106
  positive! :queue_wait
105
107
  positive! :control_deadline
108
+ positive! :sweep_interval
106
109
 
107
110
  unless queue_size.is_a?(Integer) && !queue_size.negative?
108
111
  raise ConfigurationError, "queue_size: #{queue_size} must not be negative"
@@ -18,8 +18,12 @@ module HotCell
18
18
  #
19
19
  # `Dir.exist?` is not the guard, because it follows symlinks and answers false for a dangling one, and
20
20
  # an entry a tool left in a directory's place is exactly what this has to remove.
21
+ #
22
+ # A tree that is gone by the time the removal fails is the outcome this wants, however it went. Two
23
+ # sweepers can meet on one discarded tree — the worker that answered on the slot and the supervisor's
24
+ # own — and the loser's walk fails on an entry the winner unlinked first.
21
25
  def self.remove_tree(path)
22
- return true unless File.exist?(path) || File.symlink?(path)
26
+ return true if gone?(path)
23
27
 
24
28
  FileUtils.remove_entry path
25
29
  true
@@ -27,12 +31,26 @@ module HotCell
27
31
  repair_and_remove path
28
32
  end
29
33
 
34
+ # `lstat` rather than `File.exist?`, which answers false for a path it cannot stat as well as for one that
35
+ # is gone. Only ENOENT means gone; a tree behind a directory a tool made unsearchable is still there.
36
+ def self.gone?(path)
37
+ File.lstat path
38
+ false
39
+ rescue Errno::ENOENT
40
+ true
41
+ rescue SystemCallError
42
+ false
43
+ end
44
+ private_class_method :gone?
45
+
30
46
  def self.repair_and_remove(path)
47
+ return true if gone?(path)
48
+
31
49
  FileUtils.chmod_R 0o700, path, force: true
32
50
  FileUtils.remove_entry path
33
51
  true
34
52
  rescue SystemCallError
35
- false
53
+ gone?(path)
36
54
  end
37
55
  private_class_method :repair_and_remove
38
56
  end
data/lib/hot_cell/log.rb CHANGED
@@ -35,11 +35,18 @@ module HotCell
35
35
  "worker.unforkable" => "ERROR",
36
36
  "worker.undispatchable" => "ERROR",
37
37
  "worker.unreadable_report" => "ERROR",
38
+ "sweeper.forked" => "INFO",
39
+ "sweeper.deadline" => "WARN",
40
+ "sweeper.unforkable" => "ERROR",
41
+ "sweeper.crashed" => "ERROR",
42
+ "scratch.swept" => "INFO",
38
43
  "control.abandoned" => "WARN",
39
44
  "control.unanswerable" => "WARN",
40
45
  "slot.uncleaned" => "WARN",
41
46
  "scratch.unswept" => "WARN",
42
47
  "slot.undiscarded" => "WARN",
48
+ "slot.unswept" => "WARN",
49
+ "sweeper.died" => "WARN",
43
50
  }.freeze
44
51
 
45
52
  def self.null
@@ -2,6 +2,6 @@
2
2
 
3
3
  module HotCell
4
4
  module Server
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -15,6 +15,7 @@ require "hot_cell/timing"
15
15
  require "hot_cell/counters"
16
16
  require "hot_cell/control"
17
17
  require "hot_cell/worker"
18
+ require "hot_cell/sweeper"
18
19
  require "hot_cell/supervisor"
19
20
 
20
21
  module HotCell
data/lib/hot_cell/slot.rb CHANGED
@@ -95,7 +95,8 @@ module HotCell
95
95
  #
96
96
  # A rename within one filesystem is O(1) and takes the tree out of the way. A worker sweeps it later,
97
97
  # after it has answered and before it reports itself idle — see Worker#serve, which is the one window
98
- # where the unlinking costs nobody's latency.
98
+ # where the unlinking costs nobody's latency. A worker killed at its deadline never reaches that window,
99
+ # so the supervisor also forks a Sweeper on a timer, which unlinks in a process of its own.
99
100
  #
100
101
  # The destination carries a random suffix rather than a counter, because the tool that filled the
101
102
  # directory runs as this user and can write to the slot's directory. A predictable name lets it
@@ -133,12 +134,25 @@ module HotCell
133
134
  # Unlinks whatever discard_home renamed out of the way. Partial progress is fine: a sweep killed
134
135
  # part-way leaves fewer entries for the next one, so this converges rather than repeating.
135
136
  def sweep
136
- Dir.glob(File.join(directory, "discarded-*")).map { |path| Filesystem.remove_tree(path) }.all?
137
+ discarded.map { |path| Filesystem.remove_tree(path) }.all?
137
138
  rescue SystemCallError
138
139
  # The glob itself can fail, because the slot directory is a name a tool can replace — a symlink loop
139
140
  # in its place answers ELOOP here rather than for any one entry. This runs from the worker's ensure,
140
141
  # where a raise would replace the caller's response with a crash.
141
142
  false
142
143
  end
144
+
145
+ # What discard_home has renamed aside and nobody has unlinked yet.
146
+ def discarded
147
+ Dir.glob(File.join(directory, "discarded-*"))
148
+ end
149
+
150
+ # Streams the directory and stops at the first match, because the supervisor asks this in its loop and
151
+ # a tool can put as many entries beside the discarded ones as it likes; a glob would list and sort them all.
152
+ def discarded?
153
+ Dir.each_child(directory).any? { |name| name.start_with?("discarded-") }
154
+ rescue Errno::ENOENT
155
+ false
156
+ end
143
157
  end
144
158
  end
@@ -22,11 +22,27 @@ module HotCell
22
22
  # the accept anyway, for the queue, for queued_ms, and to answer `capacity`. It also means the supervisor
23
23
  # knows when every worker started its current request, which is what the deadline needs.
24
24
  class Supervisor
25
+ # The one deadline the supervisor enforces, on a worker and on the sweeper alike: from `started_at`,
26
+ # for `deadline` seconds, killed once. `killed_for` is the one-kill latch — a killed process stays here
27
+ # until the reap, and without the latch it would be re-killed and re-logged on every pass until then.
28
+ # See `Child#overdue?` for the measurement behind that.
29
+ module Timed
30
+ def overdue?(now)
31
+ timed? && now - started_at >= deadline
32
+ end
33
+
34
+ def expires_at
35
+ started_at + deadline if timed?
36
+ end
37
+ end
38
+
25
39
  # Owns "is this worker busy" and the two transitions that change the answer, because the supervisor asking
26
40
  # `busy?` and the supervisor assigning the four fields `busy?` is computed from are the same fact. Spread
27
41
  # across the caller, a new field is one the next transition forgets to clear.
28
42
  Child = Struct.new(:slot, :pid, :control, :connection, :dispatched_at, :deadline, :served, :killed_for,
29
43
  :op, :retired_at, :buffer, :stderr, :captured, keyword_init: true) do
44
+ include Timed
45
+
30
46
  def self.build(slot:, pid:, control:, deadline:, stderr: nil)
31
47
  new slot: slot, pid: pid, control: control, deadline: deadline, served: 0, buffer: "".b,
32
48
  stderr: stderr, captured: "".b
@@ -87,12 +103,12 @@ module HotCell
87
103
  # SIGKILLs and 72 synchronous stdout writes for one breach. The window is longest exactly when the host
88
104
  # is already struggling — a worker in uninterruptible sleep, or one tearing down gigabytes of mappings —
89
105
  # and the loop it starves is the one enforcing every other request's deadline.
90
- def overdue?(now)
91
- busy? && killed_for.nil? && now - dispatched_at >= deadline
106
+ def timed?
107
+ busy? && killed_for.nil?
92
108
  end
93
109
 
94
- def expires_at
95
- dispatched_at + deadline if busy? && killed_for.nil?
110
+ def started_at
111
+ dispatched_at
96
112
  end
97
113
 
98
114
  # The retirement analogue of `overdue?`, and the only timer that can reach a worker whose idle
@@ -111,6 +127,18 @@ module HotCell
111
127
  end
112
128
  end
113
129
 
130
+ # The one sweeper the supervisor runs at a time. Not a `Child`: it is dispatched no request, holds no
131
+ # slot and answers nobody, so none of `busy?`, retirement or the idle report applies to it. What it
132
+ # shares with a worker is the deadline, and that is `Timed` — the same measurement, the same latch, the
133
+ # same `deadline` value from the configuration, and the same kill.
134
+ Sweep = Struct.new(:pid, :started_at, :deadline, :killed_for, keyword_init: true) do
135
+ include Timed
136
+
137
+ def timed?
138
+ killed_for.nil?
139
+ end
140
+ end
141
+
114
142
  # A path longer than this fails to bind with an error that does not say so. Darwin allows four fewer
115
143
  # bytes than Linux, and control.sock is the longer of the two names, so it overflows first.
116
144
  SUN_PATH_MAX = RUBY_PLATFORM.include?("darwin") ? 104 : 108
@@ -138,9 +166,10 @@ module HotCell
138
166
 
139
167
  attr_reader :configuration, :counters, :log, :directory, :workspace
140
168
 
141
- def initialize(directory:, workspace: nil, configuration: HotCell.configuration, log: Log.new,
142
- ptrace_scope_path: PTRACE_SCOPE)
169
+ def initialize(directory:, workspace: nil, development: false, configuration: HotCell.configuration,
170
+ log: Log.new, ptrace_scope_path: PTRACE_SCOPE)
143
171
  @directory = directory
172
+ @development_tmpdir = own_tmpdir if development
144
173
  @workspace = workspace || File.join(tmpdir, "hotcell-workspace")
145
174
  @configuration = configuration
146
175
  @log = log
@@ -150,12 +179,15 @@ module HotCell
150
179
  @control_pending = []
151
180
  @counters = Counters.new
152
181
  @stopping = false
182
+ @sweep = nil
183
+ @next_sweep_at = Clock.now + configuration.sweep_interval
153
184
  end
154
185
 
155
186
  def boot
156
187
  verify_socket_paths!
157
188
  verify_limits!
158
189
  verify_ptrace_scope!
190
+ claim_tmpdir if @development_tmpdir
159
191
  verify_scratches!
160
192
  prepare_directories
161
193
  preload
@@ -164,7 +196,7 @@ module HotCell
164
196
  @control_handler = Control.new(configuration: configuration, counters: counters)
165
197
  trap_signals
166
198
 
167
- log.write "cell.boot", pid: Process.pid, directory: directory, operations: Registry.names,
199
+ log.write "cell.boot", pid: Process.pid, directory: directory, tmpdir: tmpdir, operations: Registry.names,
168
200
  configuration: configuration.to_h
169
201
  self
170
202
  end
@@ -176,10 +208,12 @@ module HotCell
176
208
 
177
209
  enforce_deadlines
178
210
  enforce_retirements
211
+ enforce_sweep_deadline
179
212
  expire_queue
180
213
  expire_control
181
214
  retire_idle if @stopping
182
215
  pump
216
+ sweep_if_due
183
217
  end
184
218
  ensure
185
219
  shutdown
@@ -202,13 +236,16 @@ module HotCell
202
236
  end
203
237
 
204
238
  # The nearest thing that needs doing without anybody knocking: a deadline, a queued connection that
205
- # has waited long enough to be told so, or a control client that never said what it wanted.
239
+ # has waited long enough to be told so, a control client that never said what it wanted, or the
240
+ # sweeper's next tick.
206
241
  def wait_for
207
242
  now = Clock.now
208
243
  nearest = [ *@children.each_value.filter_map(&:expires_at),
209
244
  *@children.each_value.filter_map { |child| child.lingers_until(Configuration::KILL_GRACE) },
210
245
  *@queue.map { |(_, queued_at)| queued_at + configuration.queue_wait },
211
- *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline } ].min
246
+ *@control_pending.map { |pending| pending.accepted_at + configuration.control_deadline },
247
+ @sweep&.expires_at,
248
+ (@next_sweep_at unless @stopping) ].compact.min
212
249
  return nil if nearest.nil?
213
250
 
214
251
  [ nearest - now, 0 ].max
@@ -457,12 +494,12 @@ module HotCell
457
494
  deadline: configuration.limits.deadline, stderr: stderr_reader)
458
495
  end
459
496
 
460
- # Everything the supervisor holds and the worker must not: the listener, the signal pipe, the other
461
- # children's control sockets, and every connection the supervisor is still holding for somebody else.
462
497
  # The connection this worker is about to serve arrives over SCM_RIGHTS a moment from now, so closing
463
- # the inherited copy here costs nothing and stops it lingering for the worker's whole life.
498
+ # the inherited copy in `leave_supervisor` costs nothing and stops it lingering for the worker's whole
499
+ # life.
464
500
  def become_worker(supervisor_side, stderr_reader, stderr_writer)
465
- [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
501
+ leave_supervisor
502
+ supervisor_side.close
466
503
 
467
504
  # Its own process group, so the deadline reaches the tools this request started rather than only the
468
505
  # Ruby process that started them. A tool is a grandchild — the worker spawns it — and killing the
@@ -485,20 +522,6 @@ module HotCell
485
522
  # reasons that section records. Landlock is the candidate that fits, tracked at basecamp/hotcell#13.
486
523
  Process.setpgid 0, 0
487
524
 
488
- supervisor_side.close
489
- @signals.close
490
- @signal_writer.close
491
- @work.close
492
- @control.close
493
-
494
- @children.each_value do |child|
495
- child.control.close
496
- child.connection&.close
497
- child.stderr&.close
498
- end
499
- @queue.each { |(connection, _)| connection.close }
500
- @control_pending.each { |pending| pending.connection.close }
501
-
502
525
  # fd 2 becomes the pipe, and it stays non-blocking. `IO.pipe` already returns both ends O_NONBLOCK
503
526
  # and `reopen` is a dup2, which shares the file description — so the flag would ride along on its
504
527
  # own. It is set here anyway, because a decision this load-bearing should be in the code rather than
@@ -516,6 +539,80 @@ module HotCell
516
539
  stderr_writer.close
517
540
  end
518
541
 
542
+ # Everything the supervisor holds and a child of its must not: the listeners, the signal pipe, the
543
+ # children's control sockets, and every connection the supervisor is still holding for somebody else.
544
+ def leave_supervisor
545
+ [ "CHLD", "INT", "TERM" ].each { |signal| trap signal, "DEFAULT" }
546
+
547
+ @signals.close
548
+ @signal_writer.close
549
+ @work.close
550
+ @control.close
551
+
552
+ @children.each_value do |child|
553
+ child.control.close
554
+ child.connection&.close
555
+ child.stderr&.close
556
+ end
557
+ @queue.each { |(connection, _)| connection.close }
558
+ @control_pending.each { |pending| pending.connection.close }
559
+ end
560
+
561
+ # Ticks whether or not it forks, so a sweep that outlives an interval is left to its deadline rather
562
+ # than joined by a second one. Forking only when a slot holds a tree keeps an idle cell from paying
563
+ # for a child every interval; the glob it costs is the one `Slot#discard_home` already runs inline.
564
+ def sweep_if_due
565
+ return if @stopping || Clock.now < @next_sweep_at
566
+
567
+ @next_sweep_at = Clock.now + configuration.sweep_interval
568
+ spawn_sweeper if @sweep.nil? && discarded_anywhere?
569
+ end
570
+
571
+ # A glob that raises is a slot directory a tool replaced, and the sweeper is the process that reports
572
+ # that, so it is forked to find out.
573
+ def discarded_anywhere?
574
+ (0...configuration.concurrency).any? { |number| Slot.build(workspace, number).discarded? }
575
+ rescue SystemCallError
576
+ true
577
+ end
578
+
579
+ # Held to the cell's deadline: a tree that takes longer to unlink than a request is allowed to run is
580
+ # one an input built to, and the next tick sweeps what this one left. A fork that fails is the host
581
+ # under pressure, as in `spawn`; the trees wait for the next tick.
582
+ def spawn_sweeper
583
+ pid = fork do
584
+ leave_supervisor
585
+ Sweeper.new(workspace: workspace, configuration: configuration, log: log).run
586
+ end
587
+
588
+ log.write "sweeper.forked", pid: pid
589
+ @sweep = Sweep.new(pid: pid, started_at: Clock.now, deadline: configuration.limits.deadline)
590
+ rescue SystemCallError => error
591
+ log.write "sweeper.unforkable", error: error.class.name, message: error.message
592
+ end
593
+
594
+ # Kill first, log second, as `enforce_deadlines` does. The sweeper spawns nothing and leads no group,
595
+ # so `kill_group` reaches it through its fallback to the bare pid.
596
+ def enforce_sweep_deadline
597
+ return unless @sweep&.overdue?(Clock.now)
598
+
599
+ @sweep.killed_for = Codes::DEADLINE
600
+ kill_group @sweep
601
+ log.write "sweeper.deadline", pid: @sweep.pid, deadline_s: @sweep.deadline
602
+ end
603
+
604
+ # A sweeper this supervisor killed already has its `sweeper.deadline` line. Any other abnormal end —
605
+ # the OOM killer, a sibling's signal, a crash `Sweeper#run` could not catch — would otherwise leave
606
+ # only `sweeper.forked` behind, and look exactly like a sweep that finished.
607
+ def reap_sweeper(status)
608
+ unless @sweep.killed_for || status.success?
609
+ log.write "sweeper.died", pid: @sweep.pid, signal: signal_name(status), exit_code: status.exitstatus
610
+ end
611
+
612
+ @sweep = nil
613
+ end
614
+
615
+
519
616
  # One bounded read per pass, never a loop until the pipe is empty: this runs inside the loop that
520
617
  # enforces every request's deadline, and the peer is a worker that can print as fast as it likes.
521
618
  #
@@ -808,6 +905,11 @@ module HotCell
808
905
  pid, status = Process.wait2(-1, Process::WNOHANG)
809
906
  break if pid.nil?
810
907
 
908
+ if @sweep&.pid == pid
909
+ reap_sweeper status
910
+ next
911
+ end
912
+
811
913
  child = @children.each_value.find { |candidate| candidate.pid == pid }
812
914
  next if child.nil?
813
915
 
@@ -1032,8 +1134,56 @@ module HotCell
1032
1134
  # `Dir.tmpdir` with its fallbacks removed: it answers `.` for a `/tmp` its owner cannot write, and a
1033
1135
  # worker can leave `/tmp` in that state. The sweep is what puts the mode back, so it has to see the
1034
1136
  # directory `Dir.tmpdir` will answer once it has.
1137
+ #
1138
+ # A cell told no `TMPDIR` sweeps the system's: right on the accessory, whose `/tmp` is its own mount, and
1139
+ # wrong on a developer's machine, where `/tmp` and macOS's per-user `TMPDIR` are shared with everything
1140
+ # else the developer runs. So `--development` never sweeps the directory it is given, unless an explicit
1141
+ # workspace makes it the workspace's parent. Its scratch is a directory of its own beneath it, named for
1142
+ # the socket directory so two cells one uid runs do not sweep each other's slots, and chosen once here so
1143
+ # the default workspace and the boot agree.
1035
1144
  def tmpdir
1036
- ENV.values_at("TMPDIR", "TMP", "TEMP").compact.reject(&:empty?).first || Etc.systmpdir
1145
+ @development_tmpdir || configured_tmpdir || Etc.systmpdir
1146
+ end
1147
+
1148
+ def configured_tmpdir
1149
+ ENV.values_at("TMPDIR", "TMP", "TEMP").compact.reject(&:empty?).first
1150
+ end
1151
+
1152
+ def own_tmpdir
1153
+ parent = configured_tmpdir || Etc.systmpdir
1154
+ File.expand_path File.join(parent, "hotcell-#{directory.delete_prefix("/").tr("/", "-")}")
1155
+ end
1156
+
1157
+ # The parent is world-writable, so the name is claimed with `mkdir` and, when it exists, read back with
1158
+ # `lstat`: it has to be a plain directory this uid owns. Its mode goes back to `0700` because the sticky
1159
+ # parent stops another uid from replacing the directory, not from writing inside one a worker opened up.
1160
+ # The ancestors are checked before `mkdir` and `chmod` could act through an owned symlink;
1161
+ # `verify_scratches!` checks them again. `ENV["TMPDIR"]` is left alone: the supervisor writes no temporary
1162
+ # files, a worker sets its own per request, and a second supervisor in this process would nest its
1163
+ # scratch inside this one.
1164
+ #
1165
+ # Accepted: an entry another uid plants inside, once a worker has opened the directory up, survives the
1166
+ # sweep, which removes only what this uid owns, and a slot follows it. Opening it up needs code already
1167
+ # running as this uid, which can reach the same files directly.
1168
+ def claim_tmpdir
1169
+ claimed = tmpdir
1170
+ if (link = owned_symlink_on(File.dirname(claimed)))
1171
+ raise ConfigurationError, "#{link} is a symlink the cell's uid owns, on the path to the scratch " \
1172
+ "#{claimed}, and a boot sweeps the scratch"
1173
+ end
1174
+
1175
+ begin
1176
+ Dir.mkdir claimed, 0o700
1177
+ rescue Errno::EEXIST
1178
+ stat = File.lstat(claimed)
1179
+ unless stat.directory? && stat.uid == Process.uid
1180
+ raise ConfigurationError, "#{claimed} is not a directory this uid owns, and a boot sweeps it. Set " \
1181
+ "TMPDIR to a directory of the cell's own."
1182
+ end
1183
+ File.chmod 0o700, claimed
1184
+ end
1185
+ rescue SystemCallError => error
1186
+ raise ConfigurationError, "the scratch #{claimed} could not be claimed: #{error.message}"
1037
1187
  end
1038
1188
 
1039
1189
  def scratches
@@ -1092,6 +1242,7 @@ module HotCell
1092
1242
  refuse_queue "the cell is stopping"
1093
1243
  @control_pending.each { |pending| pending.connection.close }
1094
1244
  @children.each_value { |child| child.control.close unless child.control.socket.closed? }
1245
+ kill_group @sweep if @sweep
1095
1246
  @work&.close
1096
1247
  @control&.close
1097
1248
  SOCKETS.each { |name| File.unlink socket_path(name) if File.socket?(socket_path(name)) }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HotCell
4
+ # Unlinks what the supervisor renamed aside, in a process of its own.
5
+ #
6
+ # How long a recursive delete takes is chosen by the input that filled the tree, so it runs in neither
7
+ # the supervisor, whose loop enforces every deadline, nor a worker's request. A worker sweeps its own
8
+ # slot after it has answered, but a worker killed at its deadline never reaches that ensure — and a slot
9
+ # whose every request is killed stacked one tree per kill until the scratch was full. This is the sweep
10
+ # that needs no request to run: the supervisor forks it on a timer, holds it to a deadline, and never
11
+ # runs two at once.
12
+ class Sweeper
13
+ def initialize(workspace:, configuration:, log:)
14
+ @workspace = workspace
15
+ @configuration = configuration
16
+ @log = log
17
+ end
18
+
19
+ # exit! for the reason Worker#run does: nothing inherited from the supervisor may run its teardown here.
20
+ #
21
+ # The cell's memory limit goes on first, and only that one. `FileUtils.remove_entry` lists a directory
22
+ # before it unlinks anything in it, so a tree one directory wide enough allocates in proportion to its
23
+ # width; RLIMIT_DATA makes that this process's NoMemoryError and a `sweeper.crashed` line. It is a bound
24
+ # on this process and not on the cell: the cgroup counts every worker and the tmpfs too, and can run out
25
+ # first. A sweep that dies this way makes no progress on that directory, and that is accepted here.
26
+ #
27
+ # Not `file_size`: this process writes nothing but log lines, and a log that is a regular file is past
28
+ # any worker's limit already, so the first line would have killed the sweeper with SIGXFSZ.
29
+ def run
30
+ configuration.limits.merge(file_size: nil, open_files: nil).apply
31
+ started = Clock.now
32
+ swept = slots.sum { |slot| sweep slot }
33
+
34
+ log.write "scratch.swept", pid: Process.pid, swept: swept, duration_ms: Clock.ms_since(started)
35
+ exit! 0
36
+ rescue Exception => error
37
+ log.write "sweeper.crashed", pid: Process.pid, error: error.class.name, message: Failure.sanitize(error.message)
38
+ exit! 1
39
+ end
40
+
41
+ private
42
+ attr_reader :workspace, :configuration, :log
43
+
44
+ def slots
45
+ (0...configuration.concurrency).map { |number| Slot.build(workspace, number) }
46
+ end
47
+
48
+ # A tree that would not go is the worker's `slot.unswept`, from this pid: the same fact, whoever
49
+ # noticed it. The glob can raise — the slot directory is a name a tool can replace — and that too is
50
+ # a slot left unswept rather than the end of the sweep.
51
+ def sweep(slot)
52
+ trees = slot.discarded
53
+ removed = trees.count { |path| Filesystem.remove_tree(path) }
54
+ report_unswept slot if removed < trees.size
55
+ removed
56
+ rescue SystemCallError
57
+ report_unswept slot
58
+ 0
59
+ end
60
+
61
+ def report_unswept(slot)
62
+ log.write "slot.unswept", pid: Process.pid, slot: slot.number, home: slot.directory
63
+ end
64
+ end
65
+ end
@@ -45,7 +45,8 @@ module HotCell
45
45
  end
46
46
 
47
47
  # Anything in `supervisor:` goes to Supervisor.new; everything else is the cell's own limits.
48
- def initialize(name: "test", supervisor: {}, operations: nil, **options)
48
+ # `own_tmpdir: false` boots the cell with no `TMPDIR` at all, the way a Procfile that sets none does.
49
+ def initialize(name: "test", supervisor: {}, operations: nil, own_tmpdir: true, **options)
49
50
  @name = name
50
51
  @supervisor_options = supervisor
51
52
  @operations = operations
@@ -54,8 +55,8 @@ module HotCell
54
55
  @directory = File.join(@root, name)
55
56
  # Boot empties `Dir.tmpdir` and the workspace's parent of what this uid owns. Inside the cell both are
56
57
  # this directory, so the sweep reaches neither the developer's `/tmp` nor the log and sockets beside it.
57
- @tmpdir = File.join(@root, "tmp")
58
- @workspace = File.join(@tmpdir, "workspace")
58
+ @tmpdir = File.join(@root, "tmp") if own_tmpdir
59
+ @workspace = File.join(@tmpdir, "workspace") if own_tmpdir
59
60
  @log_path = File.join(@root, "cell.log")
60
61
  end
61
62
 
@@ -78,8 +79,12 @@ module HotCell
78
79
 
79
80
  HotCell.limits(**@options) unless @options.empty?
80
81
 
81
- FileUtils.mkdir_p @tmpdir
82
- ENV["TMPDIR"] = @tmpdir
82
+ if @tmpdir
83
+ FileUtils.mkdir_p @tmpdir
84
+ ENV["TMPDIR"] = @tmpdir
85
+ else
86
+ %w[ TMPDIR TMP TEMP ].each { |key| ENV.delete key }
87
+ end
83
88
 
84
89
  supervisor = Supervisor.new(directory: directory, workspace: workspace,
85
90
  log: Log.new(File.open(log_path, "w")), **@supervisor_options)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.4.0
18
+ version: 0.5.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.4.0
25
+ version: 0.5.0
26
26
  description: |
27
27
  Runs a HotCell container. A supervisor listens on two Unix sockets, forks a worker for each request,
28
28
  and enforces a wall clock deadline and resource limits on it. Write the work as a subclass of
@@ -52,6 +52,7 @@ files:
52
52
  - lib/hot_cell/server/version.rb
53
53
  - lib/hot_cell/slot.rb
54
54
  - lib/hot_cell/supervisor.rb
55
+ - lib/hot_cell/sweeper.rb
55
56
  - lib/hot_cell/test_cell.rb
56
57
  - lib/hot_cell/test_operations.rb
57
58
  - lib/hot_cell/timing.rb
@@ -62,8 +63,8 @@ licenses:
62
63
  - MIT
63
64
  metadata:
64
65
  homepage_uri: https://github.com/basecamp/hotcell
65
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.4.0/hotcell-server
66
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.4.0/CHANGELOG.md
66
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.5.0/hotcell-server
67
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.5.0/CHANGELOG.md
67
68
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
68
69
  rubygems_mfa_required: 'true'
69
70
  rdoc_options: []