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 +4 -4
- data/exe/hotcell +5 -1
- data/lib/hot_cell/configuration.rb +3 -0
- data/lib/hot_cell/filesystem.rb +20 -2
- data/lib/hot_cell/log.rb +7 -0
- data/lib/hot_cell/server/version.rb +1 -1
- data/lib/hot_cell/server.rb +1 -0
- data/lib/hot_cell/slot.rb +16 -2
- data/lib/hot_cell/supervisor.rb +179 -28
- data/lib/hot_cell/sweeper.rb +65 -0
- data/lib/hot_cell/test_cell.rb +10 -5
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b69f25f352406df26617692f42c1ce78c7191df4d7b617360c62c2371e488bed
|
|
4
|
+
data.tar.gz: 34ca8c1eea5a9de3cf739ce548151ac5c9487e185739f684d179844c13257b1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"
|
data/lib/hot_cell/filesystem.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/hot_cell/server.rb
CHANGED
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
|
-
|
|
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
|
data/lib/hot_cell/supervisor.rb
CHANGED
|
@@ -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
|
|
91
|
-
busy? && killed_for.nil?
|
|
106
|
+
def timed?
|
|
107
|
+
busy? && killed_for.nil?
|
|
92
108
|
end
|
|
93
109
|
|
|
94
|
-
def
|
|
95
|
-
dispatched_at
|
|
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,
|
|
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,
|
|
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 }
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/hot_cell/test_cell.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
+
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.
|
|
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.
|
|
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.
|
|
66
|
-
changelog_uri: https://github.com/basecamp/hotcell/blob/v0.
|
|
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: []
|